Skip to main content

openvpn_mgmt_codec/
codec.rs

1use std::{borrow::Cow, collections::BTreeMap, collections::VecDeque, io};
2
3use bytes::{Buf, BufMut, BytesMut};
4use tokio_util::codec::{Decoder, Encoder};
5use tracing::{debug, warn};
6
7use crate::{
8    auth::AuthType,
9    client_event::ClientEvent,
10    command::{OvpnCommand, ResponseKind},
11    kill_target::KillTarget,
12    log_level::LogLevel,
13    message::{Notification, OvpnMessage, PasswordNotification},
14    openvpn_state::OpenVpnState,
15    proxy_action::ProxyAction,
16    redacted::Redacted,
17    remote_action::RemoteAction,
18    status_format::StatusFormat,
19    transport_protocol::TransportProtocol,
20    unrecognized::UnrecognizedKind,
21};
22
23/// Characters that are unsafe in the line-oriented management protocol:
24/// `\n` and `\r` split commands; `\0` truncates at the C layer.
25const WIRE_UNSAFE: &[char] = &['\n', '\r', '\0'];
26
27/// Controls how the encoder handles characters that are unsafe for the
28/// line-oriented management protocol (`\n`, `\r`, `\0`).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum EncoderMode {
31    /// Silently strip unsafe characters (default, defensive).
32    ///
33    /// `\n`, `\r`, and `\0` are removed from all user-supplied strings.
34    /// Block body lines equaling `"END"` are escaped to `" END"`.
35    #[default]
36    Sanitize,
37
38    /// Reject inputs containing unsafe characters with an error.
39    ///
40    /// [`Encoder::encode`] returns `Err(io::Error)` if any field contains
41    /// `\n`, `\r`, or `\0`, or if a block body line equals `"END"`.
42    /// The inner error can be downcast to [`EncodeError`] for structured
43    /// matching.
44    Strict,
45}
46
47/// Structured error for encoder-side validation failures.
48///
49/// Returned as the inner error of [`std::io::Error`] when [`EncoderMode::Strict`]
50/// is active and the input contains characters that would corrupt the wire protocol.
51#[derive(Debug, thiserror::Error)]
52pub enum EncodeError {
53    /// A field contains `\n`, `\r`, or `\0`.
54    #[error("{0} contains characters unsafe for the management protocol (\\n, \\r, or \\0)")]
55    UnsafeCharacters(&'static str),
56
57    /// A multi-line block body line equals `"END"`.
58    #[error("block body line equals \"END\", which would terminate the block early")]
59    EndInBlockBody,
60}
61
62/// Ensure a string is safe for the wire protocol.
63///
64/// In [`EncoderMode::Sanitize`]: strips `\n`, `\r`, and `\0`, returning
65/// the cleaned string (or borrowing the original if already clean).
66///
67/// In [`EncoderMode::Strict`]: returns `Err` if any unsafe characters
68/// are present.
69fn wire_safe<'a>(
70    s: &'a str,
71    field: &'static str,
72    mode: EncoderMode,
73) -> Result<Cow<'a, str>, io::Error> {
74    if !s.contains(WIRE_UNSAFE) {
75        return Ok(Cow::Borrowed(s));
76    }
77    match mode {
78        EncoderMode::Sanitize => Ok(Cow::Owned(
79            s.chars().filter(|chr| !WIRE_UNSAFE.contains(chr)).collect(),
80        )),
81        EncoderMode::Strict => Err(io::Error::other(EncodeError::UnsafeCharacters(field))),
82    }
83}
84
85/// Backslash-escape `\` and `"` per the OpenVPN config-file lexer rules
86/// ("Command Parsing" section):
87///   `\` → `\\`
88///   `"` → `\"`
89///
90/// This function performs *only* lexer escaping. Wire-safety validation
91/// or sanitization must happen upstream via [`wire_safe`].
92fn escape(s: &str) -> String {
93    let mut out = String::with_capacity(s.len());
94    for c in s.chars() {
95        match c {
96            '\\' => out.push_str("\\\\"),
97            '"' => out.push_str("\\\""),
98            _ => out.push(c),
99        }
100    }
101    out
102}
103
104/// Wrap an already-escaped string in double quotes for the wire format.
105///
106/// This is required for any user-supplied string that might contain
107/// whitespace, backslashes, or quotes — passwords, reason strings,
108/// needstr values, etc.
109fn quote(s: &str) -> String {
110    format!("\"{s}\"")
111}
112
113/// Codec-internal state for accumulating multi-line `>CLIENT:` notifications.
114#[derive(Debug)]
115struct ClientNotificationAccumulator {
116    event: ClientEvent,
117    cid: u64,
118    kid: Option<u64>,
119    env: BTreeMap<String, String>,
120}
121
122/// Controls how many items the decoder will accumulate in a multi-line
123/// response or `>CLIENT:` ENV block before returning an error.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum AccumulationLimit {
126    /// No limit on accumulated items (the default).
127    Unlimited,
128
129    /// At most this many items before the decoder returns an error.
130    Max(usize),
131}
132
133/// Tokio codec for the OpenVPN management interface.
134///
135/// The **encoder** serializes typed [`OvpnCommand`] values into correct wire-format
136/// bytes, including proper escaping and multi-line block framing.
137///
138/// The **decoder** performs the opposite operation.
139/// It uses command-tracking state to correctly distinguish single-line from
140/// multi-line responses, and accumulates multi-line `>CLIENT:` notifications
141/// into a single [`OvpnMessage`] before emitting them.
142///
143/// # Sequential usage and pipelining
144///
145/// The OpenVPN management protocol is strictly sequential: the server
146/// processes one command at a time and sends its response before reading
147/// the next command. The codec maintains a **queue** of expected response
148/// kinds — one per encoded command. This allows callers to pipeline
149/// multiple commands (encode A, then B, then C) without waiting for each
150/// response, as long as responses arrive in the same order.
151/// Outgoing bytes are not buffered by the codec itself —
152/// the [`Encoder`] implementation writes into the `BytesMut` that
153/// `tokio_util::codec::Framed` owns, and `Framed` flushes them to the
154/// socket.
155///
156/// Encoding while a multi-line response or `>CLIENT:` notification is
157/// being accumulated is still discouraged (and logged as a warning),
158/// because it means the caller is not draining the stream (emptying the
159/// read half of the codec).
160///
161/// # Notification interleaving
162///
163/// Real-time notifications (`>STATE:`, `>LOG:`, `>BYTECOUNT:`, etc.) can
164/// arrive at **any** time, including in the middle of a multi-line command
165/// response. The decoder emits these immediately as
166/// [`OvpnMessage::Notification`] without disrupting the ongoing
167/// accumulation. The completed multi-line response is emitted afterward
168/// with the interleaved notification lines excluded.
169///
170/// Consumers should always be prepared to handle `Notification` variants
171/// between sending a command and receiving its response.
172#[derive(better_default::Default)]
173pub struct OvpnCodec {
174    /// FIFO queue of expected response kinds — one per encoded command that
175    /// has not yet been fully decoded.  The encoder pushes to the back; the
176    /// decoder peeks / pops from the front.  This resolves the protocol's
177    /// ambiguity: when the decoder sees a line that is not `SUCCESS:`,
178    /// `ERROR:`, or a `>` notification, the front of this queue tells it
179    /// whether to start multi-line accumulation or emit an error.
180    ///
181    /// When the queue is empty (no pending command), the decoder falls back
182    /// to [`ResponseKind::SuccessOrError`].
183    expected_queue: VecDeque<ResponseKind>,
184
185    /// Accumulator for multi-line (END-terminated) command responses.
186    multi_line_buf: Option<Vec<String>>,
187
188    /// Accumulator for multi-line `>CLIENT:` notifications. When this is
189    /// `Some(...)`, the decoder is waiting for `>CLIENT:ENV,END`.
190    client_notification: Option<ClientNotificationAccumulator>,
191
192    /// Maximum lines to accumulate in a multi-line response.
193    ///
194    /// Defaults to `Max(10_000)` — a safety net against unbounded growth
195    /// when a history dump floods the response (e.g. `log on all` at high
196    /// verbosity). Use [`with_max_multi_line_lines`](Self::with_max_multi_line_lines)
197    /// to override.
198    #[default(AccumulationLimit::Max(10_000))]
199    max_multi_line_lines: AccumulationLimit,
200
201    /// Maximum ENV entries to accumulate for a `>CLIENT:` notification.
202    #[default(AccumulationLimit::Unlimited)]
203    max_client_env_entries: AccumulationLimit,
204
205    /// How the encoder handles unsafe characters in user-supplied strings.
206    encoder_mode: EncoderMode,
207
208    /// Whether the initial `>INFO:` banner has been seen. The first `>INFO:`
209    /// is surfaced as [`OvpnMessage::Info`]; subsequent ones become
210    /// [`Notification::Info`].
211    seen_info: bool,
212}
213
214impl OvpnCodec {
215    /// Create a new codec with default state, ready to encode commands and
216    /// decode responses.
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Set the maximum number of lines accumulated in a multi-line
222    /// response before the decoder returns an error.
223    pub fn with_max_multi_line_lines(mut self, limit: AccumulationLimit) -> Self {
224        self.max_multi_line_lines = limit;
225        self
226    }
227
228    /// Set the maximum number of ENV entries accumulated for
229    /// `>CLIENT:` notifications before the decoder returns an error.
230    pub fn with_max_client_env_entries(mut self, limit: AccumulationLimit) -> Self {
231        self.max_client_env_entries = limit;
232        self
233    }
234
235    /// Set the encoder mode for handling unsafe characters in user-supplied
236    /// strings.
237    ///
238    /// The default is [`EncoderMode::Sanitize`], which silently strips
239    /// `\n`, `\r`, and `\0`. Use [`EncoderMode::Strict`] to reject inputs
240    /// containing those characters with an error instead.
241    pub fn with_encoder_mode(mut self, mode: EncoderMode) -> Self {
242        self.encoder_mode = mode;
243        self
244    }
245
246    /// Peek at the expected response kind for the next unmatched command.
247    /// Falls back to `SuccessOrError` when no command is pending (defensive;
248    /// self-describing SUCCESS/ERROR lines will still decode correctly).
249    fn expected_front(&self) -> ResponseKind {
250        self.expected_queue
251            .front()
252            .copied()
253            .unwrap_or(ResponseKind::SuccessOrError)
254    }
255
256    /// Pop the front response kind after a complete response has been
257    /// decoded (Success, Error, MultiLine, or NoResponse).
258    fn consume_expected(&mut self) {
259        self.expected_queue.pop_front();
260    }
261}
262
263/// The decoder accumulated more items than its configured limit allows.
264#[derive(Debug, thiserror::Error)]
265#[error("{what} accumulation limit exceeded ({max})")]
266struct AccumulationLimitExceeded {
267    what: &'static str,
268    max: usize,
269}
270
271fn check_accumulation_limit(
272    current_len: usize,
273    limit: AccumulationLimit,
274    what: &'static str,
275) -> Result<(), io::Error> {
276    if let AccumulationLimit::Max(max) = limit
277        && current_len >= max
278    {
279        return Err(io::Error::other(AccumulationLimitExceeded { what, max }));
280    }
281    Ok(())
282}
283
284// --- Encoder ---
285
286impl Encoder<OvpnCommand> for OvpnCodec {
287    type Error = io::Error;
288
289    fn encode(&mut self, item: OvpnCommand, dst: &mut BytesMut) -> Result<(), Self::Error> {
290        if self.multi_line_buf.is_some() || self.client_notification.is_some() {
291            warn!(
292                "encode() called while the decoder is mid-accumulation \
293                 (multi_line_buf or client_notification is active). \
294                 Drain decode() before sending a new command."
295            );
296        }
297
298        // Push the expected response kind onto the queue so the decoder
299        // knows how to frame the corresponding response when it arrives.
300        let response_kind = item.expected_response();
301        self.expected_queue.push_back(response_kind);
302        let cmd: &str = (&item).into();
303        debug!(%cmd, expected = ?response_kind, queue_depth = self.expected_queue.len(), "encoding command");
304
305        let mode = self.encoder_mode;
306
307        match item {
308            // --- Informational ---
309            OvpnCommand::Status(StatusFormat::V1) => write_line(dst, "status"),
310            OvpnCommand::Status(ref fmt) => write_line(dst, &format!("status {fmt}")),
311            OvpnCommand::State => write_line(dst, "state"),
312            OvpnCommand::StateStream(ref m) => write_line(dst, &format!("state {m}")),
313            OvpnCommand::Version => write_line(dst, "version"),
314            OvpnCommand::SetVersion(n) => write_line(dst, &format!("version {n}")),
315            OvpnCommand::Pid => write_line(dst, "pid"),
316            OvpnCommand::Help => write_line(dst, "help"),
317            OvpnCommand::Net => write_line(dst, "net"),
318            OvpnCommand::Verb(Some(n)) => write_line(dst, &format!("verb {n}")),
319            OvpnCommand::Verb(None) => write_line(dst, "verb"),
320            OvpnCommand::Mute(Some(n)) => write_line(dst, &format!("mute {n}")),
321            OvpnCommand::Mute(None) => write_line(dst, "mute"),
322
323            // --- Real-time notification control ---
324            OvpnCommand::Log(ref m) => write_line(dst, &format!("log {m}")),
325            OvpnCommand::Echo(ref m) => write_line(dst, &format!("echo {m}")),
326            OvpnCommand::ByteCount(n) => write_line(dst, &format!("bytecount {n}")),
327
328            // --- Connection control ---
329            OvpnCommand::Signal(sig) => write_line(dst, &format!("signal {sig}")),
330            OvpnCommand::Kill(KillTarget::CommonName(ref common_name)) => {
331                let kill = format!("kill {}", wire_safe(common_name, "kill CN", mode)?);
332                write_line(dst, &kill);
333            }
334            OvpnCommand::Kill(KillTarget::Address {
335                ref protocol,
336                ref ip,
337                port,
338            }) => {
339                let safe_ip = wire_safe(ip, "kill address ip", mode)?;
340                write_line(dst, &format!("kill {protocol}:{safe_ip}:{port}",));
341            }
342            OvpnCommand::HoldQuery => write_line(dst, "hold"),
343            OvpnCommand::HoldOn => write_line(dst, "hold on"),
344            OvpnCommand::HoldOff => write_line(dst, "hold off"),
345            OvpnCommand::HoldRelease => write_line(dst, "hold release"),
346
347            // --- Authentication ---
348            //
349            // Both username and password values MUST be properly escaped.
350            // The auth type is always double-quoted on the wire.
351            OvpnCommand::Username {
352                ref auth_type,
353                ref value,
354            } => {
355                // Per the doc: username "Auth" foo
356                // Values containing special chars must be quoted+escaped:
357                //   username "Auth" "foo\"bar"
358                let auth_quoted = quote(&escape(&wire_safe(
359                    &auth_type.to_string(),
360                    "username auth_type",
361                    mode,
362                )?));
363                let val = quote(&escape(&wire_safe(value.expose(), "username value", mode)?));
364                write_line(dst, &format!("username {auth_quoted} {val}"));
365            }
366            OvpnCommand::Password {
367                ref auth_type,
368                ref value,
369            } => {
370                let auth_quoted = quote(&escape(&wire_safe(
371                    &auth_type.to_string(),
372                    "password auth_type",
373                    mode,
374                )?));
375                let val = quote(&escape(&wire_safe(value.expose(), "password value", mode)?));
376                write_line(dst, &format!("password {auth_quoted} {val}"));
377            }
378            OvpnCommand::AuthRetry(auth_retry_mode) => {
379                write_line(dst, &format!("auth-retry {auth_retry_mode}"));
380            }
381            OvpnCommand::ForgetPasswords => write_line(dst, "forget-passwords"),
382
383            // --- Challenge-response ---
384            OvpnCommand::ChallengeResponse {
385                ref state_id,
386                ref response,
387            } => {
388                let sid = wire_safe(state_id, "challenge-response state_id", mode)?;
389                let resp = wire_safe(response.expose(), "challenge-response response", mode)?;
390                let value = format!("CRV1::{sid}::{resp}");
391                let escaped = quote(&escape(&value));
392                write_line(dst, &format!("password \"Auth\" {escaped}"));
393            }
394            OvpnCommand::StaticChallengeResponse {
395                ref password_b64,
396                ref response_b64,
397            } => {
398                let password =
399                    wire_safe(password_b64.expose(), "static-challenge password_b64", mode)?;
400                let resp = wire_safe(response_b64.expose(), "static-challenge response_b64", mode)?;
401                let value = format!("SCRV1:{password}:{resp}");
402                let escaped = quote(&escape(&value));
403                write_line(dst, &format!("password \"Auth\" {escaped}"));
404            }
405
406            // --- Interactive prompts ---
407            OvpnCommand::NeedOk { ref name, response } => {
408                let name = wire_safe(name, "needok name", mode)?;
409                write_line(dst, &format!("needok {name} {response}"));
410            }
411            OvpnCommand::NeedStr {
412                ref name,
413                ref value,
414            } => {
415                let name = wire_safe(name, "needstr name", mode)?;
416                let escaped = quote(&escape(&wire_safe(value, "needstr value", mode)?));
417                write_line(dst, &format!("needstr {name} {escaped}"));
418            }
419
420            // --- PKCS#11 ---
421            OvpnCommand::Pkcs11IdCount => write_line(dst, "pkcs11-id-count"),
422            OvpnCommand::Pkcs11IdGet(idx) => write_line(dst, &format!("pkcs11-id-get {idx}")),
423
424            // --- External key (multi-line command) ---
425            //
426            // Wire format:
427            //   rsa-sig
428            //   BASE64_LINE_1
429            //   BASE64_LINE_2
430            //   END
431            OvpnCommand::RsaSig { ref base64_lines } => {
432                write_block(dst, "rsa-sig", base64_lines, mode)?;
433            }
434
435            // --- External key signature (pk-sig) ---
436            OvpnCommand::PkSig { ref base64_lines } => {
437                write_block(dst, "pk-sig", base64_lines, mode)?;
438            }
439
440            // --- ENV filter ---
441            OvpnCommand::EnvFilter(level) => write_line(dst, &format!("env-filter {level}")),
442
443            // --- Remote entry queries ---
444            OvpnCommand::RemoteEntryCount => write_line(dst, "remote-entry-count"),
445            OvpnCommand::RemoteEntryGet(ref range) => {
446                write_line(dst, &format!("remote-entry-get {range}"));
447            }
448
449            // --- Push updates ---
450            OvpnCommand::PushUpdateBroad { ref options } => {
451                let options = wire_safe(options, "push-update-broad options", mode)?;
452                let opts = quote(&escape(&options));
453                write_line(dst, &format!("push-update-broad {opts}"));
454            }
455            OvpnCommand::PushUpdateCid { cid, ref options } => {
456                let options = wire_safe(options, "push-update-cid options", mode)?;
457                let opts = quote(&escape(&options));
458                write_line(dst, &format!("push-update-cid {cid} {opts}"));
459            }
460
461            // --- Client management ---
462            //
463            // client-auth is a multi-line command:
464            //   client-auth {CID} {KID}
465            //   push "route 10.0.0.0 255.255.0.0"
466            //   END
467            // An empty config_lines produces header + immediate END.
468            OvpnCommand::ClientAuth {
469                cid,
470                kid,
471                ref config_lines,
472            } => {
473                write_block(dst, &format!("client-auth {cid} {kid}"), config_lines, mode)?;
474            }
475
476            OvpnCommand::ClientAuthNt { cid, kid } => {
477                write_line(dst, &format!("client-auth-nt {cid} {kid}"));
478            }
479
480            OvpnCommand::ClientDeny(ref deny) => {
481                let reason_quoted = quote(&escape(&wire_safe(
482                    &deny.reason,
483                    "client-deny reason",
484                    mode,
485                )?));
486                match deny.client_reason {
487                    Some(ref client_reason_str) => {
488                        let options =
489                            wire_safe(client_reason_str, "client-deny client_reason", mode)?;
490                        let client_reason_quoted = quote(&escape(&options));
491                        write_line(
492                            dst,
493                            &format!(
494                                "client-deny {} {} {reason_quoted} {client_reason_quoted}",
495                                deny.cid, deny.kid
496                            ),
497                        );
498                    }
499                    None => write_line(
500                        dst,
501                        &format!("client-deny {} {} {reason_quoted}", deny.cid, deny.kid),
502                    ),
503                }
504            }
505
506            OvpnCommand::ClientKill { cid, ref message } => match message {
507                Some(msg) => write_line(
508                    dst,
509                    &format!(
510                        "client-kill {cid} {}",
511                        wire_safe(msg, "client-kill message", mode)?
512                    ),
513                ),
514                None => write_line(dst, &format!("client-kill {cid}")),
515            },
516
517            // --- Server statistics ---
518            OvpnCommand::LoadStats => write_line(dst, "load-stats"),
519
520            // --- Extended client management ---
521            OvpnCommand::ClientPendingAuth {
522                cid,
523                kid,
524                ref extra,
525                timeout,
526            } => {
527                // Real-world limit discovered by jkroepke/openvpn-auth-oauth2
528                // (used for WEB_AUTH URLs). Not documented in management-notes.txt.
529                if extra.len() > 245 {
530                    warn!(
531                        len = extra.len(),
532                        max = 245,
533                        "client-pending-auth extra exceeds 245-character limit; \
534                         OpenVPN may truncate or reject it"
535                    );
536                }
537                let extra = wire_safe(extra, "client-pending-auth extra", mode)?;
538                let pending_auth = format!("client-pending-auth {cid} {kid} {extra} {timeout}");
539                write_line(dst, &pending_auth)
540            }
541
542            OvpnCommand::CrResponse { ref response } => {
543                let response = wire_safe(response.expose(), "cr-response", mode)?;
544                write_line(dst, &format!("cr-response {response}"));
545            }
546
547            // --- External certificate ---
548            OvpnCommand::Certificate { ref pem_lines } => {
549                write_block(dst, "certificate", pem_lines, mode)?;
550            }
551
552            // --- Remote/Proxy ---
553            OvpnCommand::Remote(RemoteAction::Accept) => write_line(dst, "remote ACCEPT"),
554            OvpnCommand::Remote(RemoteAction::Skip) => write_line(dst, "remote SKIP"),
555            OvpnCommand::Remote(RemoteAction::SkipN(n)) => {
556                write_line(dst, &format!("remote SKIP {n}"));
557            }
558            OvpnCommand::Remote(RemoteAction::Modify { ref host, port }) => {
559                let host = wire_safe(host, "remote MOD host", mode)?;
560                write_line(dst, &format!("remote MOD {host} {port}"));
561            }
562            OvpnCommand::Proxy(ProxyAction::None) => write_line(dst, "proxy NONE"),
563            OvpnCommand::Proxy(ProxyAction::Http {
564                ref host,
565                port,
566                non_cleartext_only,
567            }) => {
568                let nct = if non_cleartext_only { " nct" } else { "" };
569                let host = wire_safe(host, "proxy HTTP host", mode)?;
570                write_line(dst, &format!("proxy HTTP {host} {port}{nct}"));
571            }
572            OvpnCommand::Proxy(ProxyAction::Socks { ref host, port }) => {
573                let host = wire_safe(host, "proxy SOCKS host", mode)?;
574                write_line(dst, &format!("proxy SOCKS {host} {port}"));
575            }
576
577            // --- Management interface auth ---
578            // Bare line, no quoting — the management password protocol
579            // does not use the config-file lexer.
580            OvpnCommand::ManagementPassword(ref pw) => {
581                write_line(dst, &wire_safe(pw.expose(), "management password", mode)?);
582            }
583
584            // --- Lifecycle ---
585            OvpnCommand::Exit => write_line(dst, "exit"),
586            OvpnCommand::Quit => write_line(dst, "quit"),
587
588            // --- Escape hatch ---
589            OvpnCommand::Raw(ref cmd) | OvpnCommand::RawMultiLine(ref cmd) => {
590                write_line(dst, &wire_safe(cmd, "raw command", mode)?);
591            }
592        }
593
594        Ok(())
595    }
596}
597
598/// Write a single line followed by `\n`.
599fn write_line(dst: &mut BytesMut, s: &str) {
600    dst.reserve(s.len() + 1);
601    dst.put_slice(s.as_bytes());
602    dst.put_u8(b'\n');
603}
604
605/// Write a multi-line block: header line, body lines, and a terminating `END`.
606///
607/// In [`EncoderMode::Sanitize`] mode, body lines have `\n`, `\r`, and `\0`
608/// stripped, and any line that would be exactly `"END"` is escaped to
609/// `" END"` so the server does not treat it as the block terminator.
610///
611/// In [`EncoderMode::Strict`] mode, body lines containing unsafe characters
612/// or equaling `"END"` cause an error.
613fn write_block(
614    dst: &mut BytesMut,
615    header: &str,
616    lines: &[String],
617    mode: EncoderMode,
618) -> Result<(), io::Error> {
619    // Upper-bound byte count for the entire block on the wire:
620    //   header.len()  — header line text (e.g. ">client-auth 7 42")
621    //   + 1           — '\n' terminating the header
622    //   + Σ(l.len()+2)— each body line's text + "\r\n" (2 bytes)
623    //   + 4           — "END\n" block terminator
624    // This is a conservative estimate: sanitized lines may shrink or grow
625    // slightly, but over-reserving is harmless — only written bytes count.
626    let total: usize =
627        header.len() + 1 + lines.iter().map(|line| line.len() + 2).sum::<usize>() + 4;
628    dst.reserve(total);
629    dst.put_slice(header.as_bytes());
630    dst.put_u8(b'\n');
631    for line in lines {
632        let clean = wire_safe(line, "block body line", mode)?;
633        if *clean == *"END" {
634            match mode {
635                EncoderMode::Sanitize => {
636                    dst.put_slice(b" END");
637                    dst.put_u8(b'\n');
638                    continue;
639                }
640                EncoderMode::Strict => {
641                    return Err(io::Error::other(EncodeError::EndInBlockBody));
642                }
643            }
644        }
645        dst.put_slice(clean.as_bytes());
646        dst.put_u8(b'\n');
647    }
648    dst.put_slice(b"END\n");
649    Ok(())
650}
651
652/// The password prompt.
653///
654/// May arrive without a trailing newline
655/// (OpenVPN ≥ 2.6 sends it as an interactive prompt, expecting
656/// the password on the same line).
657/// Handle this only when no complete line is available —
658/// if `\n` is in the buffer,
659/// the normal line-based path below handles it correctly.
660const PW_PROMPT: &[u8] = b"ENTER PASSWORD:";
661
662// --- Decoder ---
663
664impl Decoder for OvpnCodec {
665    type Item = OvpnMessage;
666    type Error = io::Error;
667
668    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
669        loop {
670            // Find the next complete line.
671            let Some(newline_pos) = src.iter().position(|&b| b == b'\n') else {
672                // No complete line yet. Check for a password prompt
673                // without a trailing newline (OpenVPN ≥ 2.6 sends it as
674                // an interactive prompt with no line terminator).
675                // We accept any buffer that starts with the prompt text
676                // since no `\n` is present (checked above). Consume the
677                // prompt and any trailing `\r`.
678                if src.starts_with(PW_PROMPT) {
679                    let mut consume = PW_PROMPT.len();
680                    if src.get(consume) == Some(&b'\r') {
681                        consume += 1;
682                    }
683                    src.advance(consume);
684                    return Ok(Some(OvpnMessage::PasswordPrompt));
685                }
686                // Hint to Framed: reserve enough for a typical management
687                // protocol line so the next read_buf doesn't micro-allocate.
688                if src.capacity() - src.len() < 256 {
689                    src.reserve(256);
690                }
691                return Ok(None); // Need more data.
692            };
693
694            // Extract the line and advance the buffer past the newline.
695            let line_bytes = src.split_to(newline_pos + 1);
696            let line = match std::str::from_utf8(&line_bytes) {
697                Ok(text) => text,
698                Err(error) => {
699                    // Reset all accumulation state so the decoder doesn't
700                    // remain stuck in a half-finished multi-line block.
701                    self.multi_line_buf = None;
702                    self.client_notification = None;
703                    self.expected_queue.clear();
704                    return Err(io::Error::new(io::ErrorKind::InvalidData, error));
705                }
706            }
707            .trim_end_matches(['\r', '\n'])
708            .to_string();
709
710            // Bare newlines (empty lines) carry no information when the
711            // decoder is not inside an accumulation context AND is not
712            // expecting a multi-line response. Skip them silently rather
713            // than emitting Unrecognized. This also absorbs the trailing
714            // `\n` when the password prompt was already consumed without
715            // a line terminator (OpenVPN ≥ 2.6).
716            if line.is_empty()
717                && self.multi_line_buf.is_none()
718                && self.client_notification.is_none()
719                && !matches!(self.expected_front(), ResponseKind::MultiLine)
720            {
721                continue;
722            }
723
724            // --- Phase 1: Multi-line >CLIENT: accumulation ---
725            //
726            // When we're accumulating a CLIENT notification, >CLIENT:ENV
727            // lines belong to it. The block terminates with >CLIENT:ENV,END.
728            // The spec guarantees atomicity for CLIENT notifications, so
729            // interleaving here should not occur. Any other line (SUCCESS,
730            // ERROR, other notifications) falls through to normal processing
731            // as a defensive measure.
732            if let Some(ref mut accum) = self.client_notification
733                && let Some(rest) = line.strip_prefix(">CLIENT:ENV,")
734            {
735                if rest == "END" {
736                    let finished = self.client_notification.take().expect("guarded by if-let");
737                    debug!(event = ?finished.event, cid = finished.cid, env_count = finished.env.len(), "decoded CLIENT notification");
738                    return Ok(Some(OvpnMessage::Notification(Notification::Client {
739                        event: finished.event,
740                        cid: finished.cid,
741                        kid: finished.kid,
742                        env: finished.env,
743                    })));
744                } else {
745                    // Parse "key=value" (value may contain '=').
746                    let (k, v) = rest
747                        .split_once('=')
748                        .map(|(k, v)| (k.to_string(), v.to_string()))
749                        .unwrap_or_else(|| (rest.to_string(), String::new()));
750                    check_accumulation_limit(
751                        accum.env.len(),
752                        self.max_client_env_entries,
753                        "client ENV",
754                    )?;
755                    accum.env.insert(k, v);
756                    continue; // Next line.
757                }
758            }
759            // Not a >CLIENT:ENV line — fall through to normal processing.
760            // This handles interleaved notifications or unexpected output.
761
762            // --- Phase 2: Multi-line command response accumulation ---
763            if let Some(ref mut buf) = self.multi_line_buf {
764                if line == "END" {
765                    let lines = self.multi_line_buf.take().expect("guarded by if-let");
766                    self.consume_expected();
767                    debug!(line_count = lines.len(), "decoded multi-line response");
768                    return Ok(Some(OvpnMessage::MultiLine(lines)));
769                }
770                // The spec only guarantees atomicity for CLIENT notifications,
771                // not for command responses — real-time notifications (>STATE:,
772                // >LOG:, etc.) can arrive mid-response. Emit them immediately
773                // without breaking the accumulation.
774                if line.starts_with('>') {
775                    if let Some(msg) = self.parse_notification(&line) {
776                        return Ok(Some(msg));
777                    }
778                    // parse_notification returns None when it starts a CLIENT
779                    // accumulation. Loop to read the next line.
780                    continue;
781                }
782                check_accumulation_limit(
783                    buf.len(),
784                    self.max_multi_line_lines,
785                    "multi-line response",
786                )?;
787                buf.push(line);
788                continue; // Next line.
789            }
790
791            // --- Phase 3: Self-describing lines ---
792            //
793            // SUCCESS: and ERROR: are unambiguous. We match on "SUCCESS:"
794            // without requiring a trailing space — the doc shows
795            // "SUCCESS: [text]" but text could be empty.
796            if let Some(rest) = line.strip_prefix("SUCCESS:") {
797                self.consume_expected();
798                return Ok(Some(OvpnMessage::Success(
799                    rest.strip_prefix(' ').unwrap_or(rest).to_string(),
800                )));
801            }
802            if let Some(rest) = line.strip_prefix("ERROR:") {
803                self.consume_expected();
804                return Ok(Some(OvpnMessage::Error(
805                    rest.strip_prefix(' ').unwrap_or(rest).to_string(),
806                )));
807            }
808
809            // Management interface password prompt (no `>` prefix).
810            if line == "ENTER PASSWORD:" {
811                return Ok(Some(OvpnMessage::PasswordPrompt));
812            }
813
814            // Real-time notifications.
815            if line.starts_with('>') {
816                if let Some(msg) = self.parse_notification(&line) {
817                    return Ok(Some(msg));
818                }
819                // Started CLIENT notification accumulation — loop for ENV lines.
820                continue;
821            }
822
823            // --- Phase 4: Ambiguous lines — use command tracking ---
824            //
825            // The line is not self-describing (no SUCCESS/ERROR/> prefix).
826            // Use the expected-response state from the last encoded command
827            // to decide how to frame it.
828            match self.expected_front() {
829                ResponseKind::MultiLine => {
830                    if line == "END" {
831                        // Edge case: empty multi-line block (header-less).
832                        self.consume_expected();
833                        return Ok(Some(OvpnMessage::MultiLine(Vec::new())));
834                    }
835                    self.multi_line_buf = Some(vec![line]);
836                    continue; // Accumulate until END.
837                }
838                ResponseKind::SuccessOrError | ResponseKind::NoResponse => {
839                    self.consume_expected();
840                    warn!(line = %line, "unrecognized line from server");
841                    return Ok(Some(OvpnMessage::Unrecognized {
842                        line,
843                        kind: UnrecognizedKind::UnexpectedLine,
844                    }));
845                }
846            }
847        }
848    }
849}
850
851impl OvpnCodec {
852    /// Parse a `>` notification line. Returns `Some(msg)` for single-line
853    /// notifications and `None` when a multi-line CLIENT accumulation has
854    /// been started (the caller should continue reading lines).
855    fn parse_notification(&mut self, line: &str) -> Option<OvpnMessage> {
856        let inner = &line[1..]; // Strip leading `>`
857
858        let Some((kind, payload)) = inner.split_once(':') else {
859            // Malformed notification — no colon.
860            warn!(line = %line, "malformed notification (no colon)");
861            return Some(OvpnMessage::Unrecognized {
862                line: line.to_string(),
863                kind: UnrecognizedKind::MalformedNotification,
864            });
865        };
866
867        // >INFO: on the very first line is the connection banner — surface
868        // it as OvpnMessage::Info. All subsequent >INFO: lines (e.g.
869        // >INFO:WEB_AUTH::url) are routed to Notification::Info.
870        if kind == "INFO" {
871            if !self.seen_info {
872                self.seen_info = true;
873                return Some(OvpnMessage::Info(payload.to_string()));
874            }
875            return Some(OvpnMessage::Notification(Notification::Info {
876                message: payload.to_string(),
877            }));
878        }
879
880        // >CLIENT: may be multi-line. Inspect the sub-type to decide.
881        if kind == "CLIENT" {
882            let (event, args) = payload
883                .split_once(',')
884                .map(|(event_str, args_str)| (event_str.to_string(), args_str.to_string()))
885                .unwrap_or_else(|| (payload.to_string(), String::new()));
886
887            // ADDRESS notifications are always single-line (no ENV block).
888            if event == "ADDRESS" {
889                let mut parts = args.splitn(3, ',');
890                let cid = parts
891                    .next()
892                    .and_then(|field| parse_field(field, "client address cid"))
893                    .unwrap_or(0);
894                let addr = parts.next().unwrap_or("").to_string();
895                let primary = parts.next() == Some("1");
896                return Some(OvpnMessage::Notification(Notification::ClientAddress {
897                    cid,
898                    addr,
899                    primary,
900                }));
901            }
902
903            // CONNECT, REAUTH, ESTABLISHED, DISCONNECT, and CR_RESPONSE all
904            // have ENV blocks. Parse CID, optional KID, and (for CR_RESPONSE)
905            // the trailing base64 response from the args.
906            let mut id_parts = args.splitn(3, ',');
907            let cid = id_parts
908                .next()
909                .and_then(|field| parse_field(field, "client cid"))
910                .unwrap_or(0);
911            let kid = id_parts
912                .next()
913                .and_then(|field| parse_field(field, "client kid"));
914
915            let parsed_event = if event == "CR_RESPONSE" {
916                let response = id_parts.next().unwrap_or("").to_string();
917                ClientEvent::CrResponse(response)
918            } else {
919                event
920                    .parse()
921                    .inspect_err(|error| warn!(%error, "unknown client event"))
922                    .unwrap_or_else(|_| ClientEvent::Unknown(event.clone()))
923            };
924
925            // Start accumulation — don't emit anything yet.
926            self.client_notification = Some(ClientNotificationAccumulator {
927                event: parsed_event,
928                cid,
929                kid,
930                env: BTreeMap::new(),
931            });
932            return None; // Signal to the caller to keep reading.
933        }
934
935        // Dispatch to typed parsers. On parse failure, fall back to Simple.
936        let notification = match kind {
937            "STATE" => parse_state(payload),
938            "BYTECOUNT" => parse_bytecount(payload),
939            "BYTECOUNT_CLI" => parse_bytecount_cli(payload),
940            "LOG" => parse_log(payload),
941            "ECHO" => parse_echo(payload),
942            "HOLD" => Some(Notification::Hold {
943                text: payload.to_string(),
944            }),
945            "FATAL" => Some(Notification::Fatal {
946                message: payload.to_string(),
947            }),
948            "PKCS11ID-COUNT" => parse_pkcs11id_count(payload),
949            "NEED-OK" => parse_need_ok(payload),
950            "NEED-STR" => parse_need_str(payload),
951            "RSA_SIGN" => Some(Notification::RsaSign {
952                data: payload.to_string(),
953            }),
954            "PK_SIGN" => parse_pk_sign(payload),
955            "INFOMSG" => Some(Notification::InfoMsg {
956                extra: payload.to_string(),
957            }),
958            "NEED-CERTIFICATE" => Some(Notification::NeedCertificate {
959                hint: payload.to_string(),
960            }),
961            "REMOTE" => parse_remote(payload),
962            "PROXY" => parse_proxy(payload),
963            "PASSWORD" => parse_password(payload),
964            "PKCS11ID-ENTRY" => {
965                return parse_pkcs11id_entry_notif(payload).or_else(|| {
966                    Some(OvpnMessage::Notification(Notification::Simple {
967                        kind: kind.to_string(),
968                        payload: payload.to_string(),
969                    }))
970                });
971            }
972            _ => None,
973        };
974
975        Some(OvpnMessage::Notification(notification.unwrap_or(
976            Notification::Simple {
977                kind: kind.to_string(),
978                payload: payload.to_string(),
979            },
980        )))
981    }
982}
983
984// --- Notification parsers ---
985//
986// Each returns `Option<Notification>`. `None` means "could not parse,
987// fall back to Simple". This is intentional — the protocol varies
988// across OpenVPN versions and we never want a parse failure to
989// produce an error.
990
991/// Parse a port field that may be empty. Empty or whitespace-only strings
992/// yield `None`; non-empty non-numeric strings also yield `None` (the STATE
993/// notification degrades gracefully via the caller's `?` on other fields).
994fn parse_optional_port(input: &str) -> Option<u16> {
995    let trimmed = input.trim();
996    if trimmed.is_empty() {
997        return None;
998    }
999    trimmed
1000        .parse()
1001        .inspect_err(
1002            |error| warn!(%error, port = trimmed, "non-numeric port in STATE notification"),
1003        )
1004        .ok()
1005}
1006
1007/// Parse a string into `T`, logging a warning on failure and returning `None`.
1008///
1009/// Used by the notification parsers that degrade to `Notification::Simple`
1010/// rather than failing hard.
1011fn parse_field<T: std::str::FromStr>(value: &str, field: &str) -> Option<T>
1012where
1013    T::Err: std::fmt::Display,
1014{
1015    value
1016        .parse()
1017        .inspect_err(|error| warn!(%error, value, field, "failed to parse notification field"))
1018        .ok()
1019}
1020
1021fn parse_state(payload: &str) -> Option<Notification> {
1022    // Wire format per management-notes.txt:
1023    //   (a) timestamp, (b) state, (c) desc, (d) local_ip, (e) remote_ip,
1024    //   (f) remote_port, (g) local_addr, (h) local_port, (i) local_ipv6
1025    let mut parts = payload.splitn(9, ',');
1026    let timestamp = parse_field(parts.next()?, "state timestamp")?;
1027    let state_str = parts.next()?;
1028    let name = state_str
1029        .parse()
1030        .inspect_err(|error| warn!(%error, "unknown OpenVPN state"))
1031        .unwrap_or_else(|_| OpenVpnState::Unknown(state_str.to_string()));
1032    let description = parts.next()?.to_string();
1033    let local_ip = parts.next()?.to_string();
1034    let remote_ip = parts.next()?.to_string();
1035    let remote_port = parse_optional_port(parts.next().unwrap_or(""));
1036    let local_addr = parts.next().unwrap_or("").to_string();
1037    let local_port = parse_optional_port(parts.next().unwrap_or(""));
1038    let local_ipv6 = parts.next().unwrap_or("").to_string();
1039    Some(Notification::State {
1040        timestamp,
1041        name,
1042        description,
1043        local_ip,
1044        remote_ip,
1045        remote_port,
1046        local_addr,
1047        local_port,
1048        local_ipv6,
1049    })
1050}
1051
1052fn parse_bytecount(payload: &str) -> Option<Notification> {
1053    let (a, b) = payload.split_once(',')?;
1054    Some(Notification::ByteCount {
1055        bytes_in: parse_field(a, "bytecount bytes_in")?,
1056        bytes_out: parse_field(b, "bytecount bytes_out")?,
1057    })
1058}
1059
1060fn parse_bytecount_cli(payload: &str) -> Option<Notification> {
1061    let mut parts = payload.splitn(3, ',');
1062    let cid = parse_field(parts.next()?, "bytecount_cli cid")?;
1063    let bytes_in = parse_field(parts.next()?, "bytecount_cli bytes_in")?;
1064    let bytes_out = parse_field(parts.next()?, "bytecount_cli bytes_out")?;
1065    Some(Notification::ByteCountCli {
1066        cid,
1067        bytes_in,
1068        bytes_out,
1069    })
1070}
1071
1072fn parse_log(payload: &str) -> Option<Notification> {
1073    let (ts_str, rest) = payload.split_once(',')?;
1074    let timestamp = parse_field(ts_str, "log timestamp")?;
1075    let (level_str, message) = rest.split_once(',')?;
1076    Some(Notification::Log {
1077        timestamp,
1078        level: level_str
1079            .parse()
1080            .inspect_err(|error| warn!(%error, "unknown log level"))
1081            .unwrap_or_else(|_| LogLevel::Unknown(level_str.to_string())),
1082        message: message.to_string(),
1083    })
1084}
1085
1086fn parse_echo(payload: &str) -> Option<Notification> {
1087    let (ts_str, param) = payload.split_once(',')?;
1088    let timestamp = parse_field(ts_str, "echo timestamp")?;
1089    Some(Notification::Echo {
1090        timestamp,
1091        param: param.to_string(),
1092    })
1093}
1094
1095fn parse_pkcs11id_count(payload: &str) -> Option<Notification> {
1096    let count = parse_field(payload.trim(), "pkcs11id_count")?;
1097    Some(Notification::Pkcs11IdCount { count })
1098}
1099
1100/// Parse `>PKCS11ID-ENTRY:'idx', ID:'id', BLOB:'blob'` from the notification
1101/// payload (after the kind and colon have been stripped).
1102fn parse_pkcs11id_entry_notif(payload: &str) -> Option<OvpnMessage> {
1103    let rest = payload.strip_prefix('\'')?;
1104    let (index, rest) = rest.split_once("', ID:'")?;
1105    let (id, rest) = rest.split_once("', BLOB:'")?;
1106    let blob = rest.strip_suffix('\'')?;
1107    Some(OvpnMessage::Pkcs11IdEntry {
1108        index: index.to_string(),
1109        id: id.to_string(),
1110        blob: blob.to_string(),
1111    })
1112}
1113
1114/// Parse `Need 'name' ... MSG:message` from NEED-OK payload.
1115fn parse_need_ok(payload: &str) -> Option<Notification> {
1116    // Format: Need 'name' confirmation MSG:message
1117    let rest = payload.strip_prefix("Need '")?;
1118    let (name, rest) = rest.split_once('\'')?;
1119    let msg = rest.split_once("MSG:")?.1;
1120    Some(Notification::NeedOk {
1121        name: name.to_string(),
1122        message: msg.to_string(),
1123    })
1124}
1125
1126/// Parse `Need 'name' input MSG:message` from NEED-STR payload.
1127fn parse_need_str(payload: &str) -> Option<Notification> {
1128    let rest = payload.strip_prefix("Need '")?;
1129    let (name, rest) = rest.split_once('\'')?;
1130    let msg = rest.split_once("MSG:")?.1;
1131    Some(Notification::NeedStr {
1132        name: name.to_string(),
1133        message: msg.to_string(),
1134    })
1135}
1136
1137/// Parse `>PK_SIGN:base64_data[,algorithm]`.
1138///
1139/// The algorithm field is only present when the management client announced
1140/// version > 2 via the `version` command.
1141///
1142/// Source: [`management-notes.txt`](https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt),
1143/// [`ssl_openssl.c` `get_sig_from_man()`](https://github.com/OpenVPN/openvpn/blob/master/src/openvpn/ssl_openssl.c).
1144fn parse_pk_sign(payload: &str) -> Option<Notification> {
1145    if payload.is_empty() {
1146        return None;
1147    }
1148    let (data, algorithm) = match payload.split_once(',') {
1149        Some((d, a)) => (d.to_string(), Some(a.to_string())),
1150        None => (payload.to_string(), None),
1151    };
1152    Some(Notification::PkSign { data, algorithm })
1153}
1154
1155fn parse_remote(payload: &str) -> Option<Notification> {
1156    let mut parts = payload.splitn(3, ',');
1157    let host = parts.next()?.to_string();
1158    let port = parse_field(parts.next()?, "remote port")?;
1159    let proto_str = parts.next()?;
1160    let protocol = proto_str
1161        .parse()
1162        .inspect_err(|error| warn!(%error, "unknown transport protocol"))
1163        .unwrap_or_else(|_| TransportProtocol::Unknown(proto_str.to_string()));
1164    Some(Notification::Remote {
1165        host,
1166        port,
1167        protocol,
1168    })
1169}
1170
1171fn parse_proxy(payload: &str) -> Option<Notification> {
1172    // Wire: >PROXY:{index},{type},{host}  (3 fields per init.c)
1173    let mut parts = payload.splitn(3, ',');
1174    let index = parse_field(parts.next()?, "proxy index")?;
1175    let pt_str = parts.next()?;
1176    let proxy_type = pt_str
1177        .parse()
1178        .inspect_err(|error| warn!(%error, "unknown proxy type"))
1179        .unwrap_or_else(|_| TransportProtocol::Unknown(pt_str.to_string()));
1180    let host = parts.next()?.to_string();
1181    Some(Notification::Proxy {
1182        index,
1183        proxy_type,
1184        host,
1185    })
1186}
1187
1188/// Map a wire auth-type string to the typed enum.
1189fn parse_auth_type(s: &str) -> AuthType {
1190    s.parse()
1191        .inspect_err(|error| warn!(%error, "unknown auth type"))
1192        .unwrap_or_else(|_| AuthType::Unknown(s.to_string()))
1193}
1194
1195fn parse_password(payload: &str) -> Option<Notification> {
1196    // Auth-Token:{token}
1197    // Source: manage.c management_auth_token()
1198    if let Some(token) = payload.strip_prefix("Auth-Token:") {
1199        return Some(Notification::Password(PasswordNotification::AuthToken {
1200            token: Redacted::new(token),
1201        }));
1202    }
1203
1204    // Verification Failed: 'Auth' ['CRV1:flags:state_id:user_b64:challenge']
1205    // Verification Failed: 'Auth'
1206    if let Some(rest) = payload.strip_prefix("Verification Failed: '") {
1207        // Check for CRV1 dynamic challenge data
1208        if let Some((auth_part, crv1_part)) = rest.split_once("' ['CRV1:") {
1209            debug_assert_eq!(auth_part, "Auth", "CRV1 auth type should always be 'Auth'");
1210            let crv1_data = crv1_part.strip_suffix("']")?;
1211            let mut parts = crv1_data.splitn(4, ':');
1212            let flags = parts.next()?.to_string();
1213            let state_id = parts.next()?.to_string();
1214            let username_b64 = parts.next()?.to_string();
1215            let challenge = parts.next()?.to_string();
1216            return Some(Notification::Password(
1217                PasswordNotification::DynamicChallenge {
1218                    flags,
1219                    state_id,
1220                    username_b64,
1221                    challenge,
1222                },
1223            ));
1224        }
1225        // Bare verification failure
1226        let auth_type = rest.strip_suffix('\'')?;
1227        return Some(Notification::Password(
1228            PasswordNotification::VerificationFailed {
1229                auth_type: parse_auth_type(auth_type),
1230            },
1231        ));
1232    }
1233
1234    // Need 'type' username/password [SC:...]
1235    // Need 'type' password
1236    let rest = payload.strip_prefix("Need '")?;
1237    let (auth_type_str, rest) = rest.split_once('\'')?;
1238    let rest = rest.trim_start();
1239
1240    if let Some(after_up) = rest.strip_prefix("username/password") {
1241        let after_up = after_up.trim_start();
1242
1243        // Static challenge: SC:flag,challenge_text
1244        // flag is a multi-bit integer: bit 0 = ECHO, bit 1 = FORMAT/CONCAT
1245        if let Some(sc) = after_up.strip_prefix("SC:") {
1246            let (flag_str, challenge) = sc.split_once(',')?;
1247            let flags: u32 = parse_field(flag_str, "static challenge flags")?;
1248            return Some(Notification::Password(
1249                PasswordNotification::StaticChallenge {
1250                    echo: flags & 1 != 0,
1251                    response_concat: flags & 2 != 0,
1252                    challenge: challenge.to_string(),
1253                },
1254            ));
1255        }
1256
1257        // Plain username/password request
1258        return Some(Notification::Password(PasswordNotification::NeedAuth {
1259            auth_type: parse_auth_type(auth_type_str),
1260        }));
1261    }
1262
1263    // Need 'type' password
1264    if rest.starts_with("password") {
1265        return Some(Notification::Password(PasswordNotification::NeedPassword {
1266            auth_type: parse_auth_type(auth_type_str),
1267        }));
1268    }
1269
1270    None // Unrecognized PASSWORD sub-format — fall back to Simple
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    use crate::{
1278        auth::AuthType, client_deny::ClientDeny, client_event::ClientEvent,
1279        message::PasswordNotification, signal::Signal, status_format::StatusFormat,
1280        stream_mode::StreamMode,
1281    };
1282
1283    use bytes::BytesMut;
1284    use tokio_util::codec::{Decoder, Encoder};
1285    use tracing_test::traced_test;
1286
1287    /// Helper: encode a command and return the wire bytes as a string.
1288    fn encode_to_string(cmd: OvpnCommand) -> String {
1289        let mut codec = OvpnCodec::new();
1290        let mut buf = BytesMut::new();
1291        codec.encode(cmd, &mut buf).unwrap();
1292        String::from_utf8(buf.to_vec()).unwrap()
1293    }
1294
1295    /// Helper: feed raw bytes into a fresh codec and collect all decoded messages.
1296    fn decode_all(input: &str) -> Vec<OvpnMessage> {
1297        let mut codec = OvpnCodec::new();
1298        let mut buf = BytesMut::from(input);
1299        let mut msgs = Vec::new();
1300        while let Some(msg) = codec.decode(&mut buf).unwrap() {
1301            msgs.push(msg);
1302        }
1303        msgs
1304    }
1305
1306    /// Helper: encode a command, then feed raw response bytes, collecting messages.
1307    fn encode_then_decode(cmd: OvpnCommand, response: &str) -> Vec<OvpnMessage> {
1308        let mut codec = OvpnCodec::new();
1309        let mut enc_buf = BytesMut::new();
1310        codec.encode(cmd, &mut enc_buf).unwrap();
1311        let mut dec_buf = BytesMut::from(response);
1312        let mut msgs = Vec::new();
1313        while let Some(msg) = codec.decode(&mut dec_buf).unwrap() {
1314            msgs.push(msg);
1315        }
1316        msgs
1317    }
1318
1319    // --- Encoder tests ---
1320
1321    #[test]
1322    fn encode_status_v1() {
1323        assert_eq!(
1324            encode_to_string(OvpnCommand::Status(StatusFormat::V1)),
1325            "status\n"
1326        );
1327    }
1328
1329    #[test]
1330    fn encode_status_v3() {
1331        assert_eq!(
1332            encode_to_string(OvpnCommand::Status(StatusFormat::V3)),
1333            "status 3\n"
1334        );
1335    }
1336
1337    #[test]
1338    fn encode_signal() {
1339        assert_eq!(
1340            encode_to_string(OvpnCommand::Signal(Signal::SigUsr1)),
1341            "signal SIGUSR1\n"
1342        );
1343    }
1344
1345    #[test]
1346    fn encode_state_on_all() {
1347        assert_eq!(
1348            encode_to_string(OvpnCommand::StateStream(StreamMode::OnAll)),
1349            "state on all\n"
1350        );
1351    }
1352
1353    #[test]
1354    fn encode_state_recent() {
1355        assert_eq!(
1356            encode_to_string(OvpnCommand::StateStream(StreamMode::Recent(5))),
1357            "state 5\n"
1358        );
1359    }
1360
1361    #[test]
1362    fn encode_password_escaping() {
1363        // A password containing a backslash and a double quote must be
1364        // properly escaped on the wire.
1365        let wire = encode_to_string(OvpnCommand::Password {
1366            auth_type: AuthType::PrivateKey,
1367            value: r#"foo\"bar"#.into(),
1368        });
1369        assert_eq!(wire, "password \"Private Key\" \"foo\\\\\\\"bar\"\n");
1370    }
1371
1372    #[test]
1373    fn encode_password_simple() {
1374        let wire = encode_to_string(OvpnCommand::Password {
1375            auth_type: AuthType::Auth,
1376            value: "hunter2".into(),
1377        });
1378        assert_eq!(wire, "password \"Auth\" \"hunter2\"\n");
1379    }
1380
1381    #[test]
1382    fn encode_client_auth_with_config() {
1383        let wire = encode_to_string(OvpnCommand::ClientAuth {
1384            cid: 42,
1385            kid: 0,
1386            config_lines: vec![
1387                "push \"route 10.0.0.0 255.255.0.0\"".to_string(),
1388                "push \"dhcp-option DNS 10.0.0.1\"".to_string(),
1389            ],
1390        });
1391        assert_eq!(
1392            wire,
1393            "client-auth 42 0\n\
1394             push \"route 10.0.0.0 255.255.0.0\"\n\
1395             push \"dhcp-option DNS 10.0.0.1\"\n\
1396             END\n"
1397        );
1398    }
1399
1400    #[test]
1401    fn encode_client_auth_empty_config() {
1402        let wire = encode_to_string(OvpnCommand::ClientAuth {
1403            cid: 1,
1404            kid: 0,
1405            config_lines: vec![],
1406        });
1407        assert_eq!(wire, "client-auth 1 0\nEND\n");
1408    }
1409
1410    #[test]
1411    fn encode_client_deny_with_client_reason() {
1412        let wire = encode_to_string(OvpnCommand::ClientDeny(ClientDeny {
1413            cid: 5,
1414            kid: 0,
1415            reason: "cert revoked".to_string(),
1416            client_reason: Some("Your access has been revoked.".to_string()),
1417        }));
1418        assert_eq!(
1419            wire,
1420            "client-deny 5 0 \"cert revoked\" \"Your access has been revoked.\"\n"
1421        );
1422    }
1423
1424    #[test]
1425    fn encode_client_pending_auth() {
1426        let wire = encode_to_string(OvpnCommand::ClientPendingAuth {
1427            cid: 42,
1428            kid: 1,
1429            extra: "WEB_AUTH::https://example.com".to_string(),
1430            timeout: 120,
1431        });
1432        assert_eq!(
1433            wire,
1434            "client-pending-auth 42 1 WEB_AUTH::https://example.com 120\n"
1435        );
1436    }
1437
1438    #[test]
1439    #[traced_test]
1440    fn encode_client_pending_auth_long_extra_warns() {
1441        let long_extra = "W".repeat(246);
1442        let wire = encode_to_string(OvpnCommand::ClientPendingAuth {
1443            cid: 1,
1444            kid: 0,
1445            extra: long_extra.clone(),
1446            timeout: 60,
1447        });
1448        assert_eq!(wire, format!("client-pending-auth 1 0 {long_extra} 60\n"));
1449        assert!(logs_contain("exceeds 245-character limit"));
1450    }
1451
1452    #[test]
1453    #[traced_test]
1454    fn encode_client_pending_auth_at_limit_no_warning() {
1455        let extra = "W".repeat(245);
1456        encode_to_string(OvpnCommand::ClientPendingAuth {
1457            cid: 1,
1458            kid: 0,
1459            extra,
1460            timeout: 60,
1461        });
1462        assert!(!logs_contain("exceeds 245-character limit"));
1463    }
1464
1465    #[test]
1466    fn encode_rsa_sig() {
1467        let wire = encode_to_string(OvpnCommand::RsaSig {
1468            base64_lines: vec!["AAAA".to_string(), "BBBB".to_string()],
1469        });
1470        assert_eq!(wire, "rsa-sig\nAAAA\nBBBB\nEND\n");
1471    }
1472
1473    #[test]
1474    fn encode_remote_modify() {
1475        let wire = encode_to_string(OvpnCommand::Remote(RemoteAction::Modify {
1476            host: "vpn.example.com".to_string(),
1477            port: 1234,
1478        }));
1479        assert_eq!(wire, "remote MOD vpn.example.com 1234\n");
1480    }
1481
1482    #[test]
1483    fn encode_pk_sig() {
1484        let wire = encode_to_string(OvpnCommand::PkSig {
1485            base64_lines: vec!["AAAA".to_string(), "BBBB".to_string()],
1486        });
1487        assert_eq!(wire, "pk-sig\nAAAA\nBBBB\nEND\n");
1488    }
1489
1490    #[test]
1491    fn encode_env_filter() {
1492        assert_eq!(
1493            encode_to_string(OvpnCommand::EnvFilter(2)),
1494            "env-filter 2\n"
1495        );
1496    }
1497
1498    #[test]
1499    fn encode_remote_entry_count() {
1500        assert_eq!(
1501            encode_to_string(OvpnCommand::RemoteEntryCount),
1502            "remote-entry-count\n"
1503        );
1504    }
1505
1506    #[test]
1507    fn encode_remote_entry_get() {
1508        use crate::command::RemoteEntryRange;
1509        assert_eq!(
1510            encode_to_string(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Single(0))),
1511            "remote-entry-get 0\n"
1512        );
1513        assert_eq!(
1514            encode_to_string(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Range {
1515                from: 0,
1516                end: 3
1517            })),
1518            "remote-entry-get 0 3\n"
1519        );
1520        assert_eq!(
1521            encode_to_string(OvpnCommand::RemoteEntryGet(RemoteEntryRange::All)),
1522            "remote-entry-get all\n"
1523        );
1524    }
1525
1526    #[test]
1527    fn encode_push_update_broad() {
1528        let wire = encode_to_string(OvpnCommand::PushUpdateBroad {
1529            options: "route 10.0.0.0".to_string(),
1530        });
1531        assert_eq!(wire, "push-update-broad \"route 10.0.0.0\"\n");
1532    }
1533
1534    #[test]
1535    fn encode_push_update_cid() {
1536        let wire = encode_to_string(OvpnCommand::PushUpdateCid {
1537            cid: 42,
1538            options: "route 10.0.0.0".to_string(),
1539        });
1540        assert_eq!(wire, "push-update-cid 42 \"route 10.0.0.0\"\n");
1541    }
1542
1543    #[test]
1544    fn encode_proxy_http_nct() {
1545        let wire = encode_to_string(OvpnCommand::Proxy(ProxyAction::Http {
1546            host: "proxy.local".to_string(),
1547            port: 8080,
1548            non_cleartext_only: true,
1549        }));
1550        assert_eq!(wire, "proxy HTTP proxy.local 8080 nct\n");
1551    }
1552
1553    #[test]
1554    fn encode_needok() {
1555        use crate::need_ok::NeedOkResponse;
1556        let wire = encode_to_string(OvpnCommand::NeedOk {
1557            name: "token-insertion-request".to_string(),
1558            response: NeedOkResponse::Ok,
1559        });
1560        assert_eq!(wire, "needok token-insertion-request ok\n");
1561    }
1562
1563    #[test]
1564    fn encode_needstr() {
1565        let wire = encode_to_string(OvpnCommand::NeedStr {
1566            name: "name".to_string(),
1567            value: "John".to_string(),
1568        });
1569        assert_eq!(wire, "needstr name \"John\"\n");
1570    }
1571
1572    #[test]
1573    fn encode_forget_passwords() {
1574        assert_eq!(
1575            encode_to_string(OvpnCommand::ForgetPasswords),
1576            "forget-passwords\n"
1577        );
1578    }
1579
1580    #[test]
1581    fn encode_hold_query() {
1582        assert_eq!(encode_to_string(OvpnCommand::HoldQuery), "hold\n");
1583    }
1584
1585    #[test]
1586    fn encode_echo_on_all() {
1587        assert_eq!(
1588            encode_to_string(OvpnCommand::Echo(StreamMode::OnAll)),
1589            "echo on all\n"
1590        );
1591    }
1592
1593    // --- Decoder tests ---
1594
1595    #[test]
1596    fn decode_success() {
1597        let msgs = decode_all("SUCCESS: pid=12345\n");
1598        assert_eq!(msgs.len(), 1);
1599        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s == "pid=12345"));
1600    }
1601
1602    #[test]
1603    fn decode_success_bare() {
1604        // Edge case: SUCCESS: with no trailing text.
1605        let msgs = decode_all("SUCCESS:\n");
1606        assert_eq!(msgs.len(), 1);
1607        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s.is_empty()));
1608    }
1609
1610    #[test]
1611    fn decode_error() {
1612        let msgs = decode_all("ERROR: unknown command\n");
1613        assert_eq!(msgs.len(), 1);
1614        assert!(matches!(&msgs[0], OvpnMessage::Error(s) if s == "unknown command"));
1615    }
1616
1617    #[test]
1618    fn decode_info_notification() {
1619        let msgs = decode_all(">INFO:OpenVPN Management Interface Version 5\n");
1620        assert_eq!(msgs.len(), 1);
1621        assert!(matches!(
1622            &msgs[0],
1623            OvpnMessage::Info(s) if s == "OpenVPN Management Interface Version 5"
1624        ));
1625    }
1626
1627    #[test]
1628    fn decode_state_notification() {
1629        let msgs = decode_all(">STATE:1234567890,CONNECTED,SUCCESS,,10.0.0.1\n");
1630        assert_eq!(msgs.len(), 1);
1631        assert!(matches!(
1632            &msgs[0],
1633            OvpnMessage::Notification(Notification::State {
1634                timestamp: 1234567890,
1635                name: OpenVpnState::Connected,
1636                description,
1637                local_ip,
1638                remote_ip,
1639                ..
1640            }) if description == "SUCCESS" && local_ip.is_empty() && remote_ip == "10.0.0.1"
1641        ));
1642    }
1643
1644    #[test]
1645    fn decode_multiline_with_command_tracking() {
1646        // After encoding a `status` command, the codec expects a multi-line
1647        // response. Lines that would otherwise be ambiguous are correctly
1648        // accumulated until END.
1649        let msgs = encode_then_decode(
1650            OvpnCommand::Status(StatusFormat::V1),
1651            "OpenVPN CLIENT LIST\nCommon Name,Real Address\ntest,1.2.3.4:1234\nEND\n",
1652        );
1653        assert_eq!(msgs.len(), 1);
1654        assert!(matches!(
1655            &msgs[0],
1656            OvpnMessage::MultiLine(lines)
1657                if lines.len() == 3
1658                && lines[0] == "OpenVPN CLIENT LIST"
1659                && lines[2] == "test,1.2.3.4:1234"
1660        ));
1661    }
1662
1663    #[test]
1664    fn decode_hold_query_success() {
1665        // Bare `hold` returns SUCCESS: hold=0 or SUCCESS: hold=1
1666        let msgs = encode_then_decode(OvpnCommand::HoldQuery, "SUCCESS: hold=0\n");
1667        assert_eq!(msgs.len(), 1);
1668        assert!(matches!(&msgs[0], OvpnMessage::Success(s) if s == "hold=0"));
1669    }
1670
1671    #[test]
1672    fn decode_bare_state_multiline() {
1673        // Bare `state` returns state history lines + END
1674        let msgs = encode_then_decode(
1675            OvpnCommand::State,
1676            "1234567890,CONNECTED,SUCCESS,,10.0.0.1,,,,\nEND\n",
1677        );
1678        assert_eq!(msgs.len(), 1);
1679        assert!(matches!(
1680            &msgs[0],
1681            OvpnMessage::MultiLine(lines)
1682                if lines.len() == 1 && lines[0].starts_with("1234567890")
1683        ));
1684    }
1685
1686    #[test]
1687    fn decode_notification_during_multiline() {
1688        // A notification can arrive in the middle of a multi-line response.
1689        // It should be emitted immediately without breaking the accumulation.
1690        let msgs = encode_then_decode(
1691            OvpnCommand::Status(StatusFormat::V1),
1692            "header line\n>BYTECOUNT:1000,2000\ndata line\nEND\n",
1693        );
1694        assert_eq!(msgs.len(), 2);
1695        // First emitted message: the interleaved notification.
1696        assert!(matches!(
1697            &msgs[0],
1698            OvpnMessage::Notification(Notification::ByteCount {
1699                bytes_in: 1000,
1700                bytes_out: 2000
1701            })
1702        ));
1703        // Second: the completed multi-line block (notification is not included).
1704        assert!(matches!(
1705            &msgs[1],
1706            OvpnMessage::MultiLine(lines) if lines == &["header line", "data line"]
1707        ));
1708    }
1709
1710    #[test]
1711    fn decode_client_connect_multiline_notification() {
1712        let input = "\
1713            >CLIENT:CONNECT,0,1\n\
1714            >CLIENT:ENV,untrusted_ip=1.2.3.4\n\
1715            >CLIENT:ENV,common_name=TestClient\n\
1716            >CLIENT:ENV,END\n";
1717        let msgs = decode_all(input);
1718        assert_eq!(msgs.len(), 1);
1719        assert!(matches!(
1720            &msgs[0],
1721            OvpnMessage::Notification(Notification::Client {
1722                event: ClientEvent::Connect,
1723                cid: 0,
1724                kid: Some(1),
1725                env,
1726            }) if env.len() == 2
1727                && env.get("untrusted_ip").map(String::as_str) == Some("1.2.3.4")
1728                && env.get("common_name").map(String::as_str) == Some("TestClient")
1729        ));
1730    }
1731
1732    #[test]
1733    fn decode_client_address_single_line() {
1734        let msgs = decode_all(">CLIENT:ADDRESS,3,10.0.0.5,1\n");
1735        assert_eq!(msgs.len(), 1);
1736        assert!(matches!(
1737            &msgs[0],
1738            OvpnMessage::Notification(Notification::ClientAddress {
1739                cid: 3,
1740                addr,
1741                primary: true,
1742            }) if addr == "10.0.0.5"
1743        ));
1744    }
1745
1746    #[test]
1747    fn decode_client_disconnect() {
1748        let input = "\
1749            >CLIENT:DISCONNECT,5\n\
1750            >CLIENT:ENV,bytes_received=12345\n\
1751            >CLIENT:ENV,bytes_sent=67890\n\
1752            >CLIENT:ENV,END\n";
1753        let msgs = decode_all(input);
1754        assert_eq!(msgs.len(), 1);
1755        assert!(matches!(
1756            &msgs[0],
1757            OvpnMessage::Notification(Notification::Client {
1758                event: ClientEvent::Disconnect,
1759                cid: 5,
1760                kid: None,
1761                env,
1762            }) if env.len() == 2
1763        ));
1764    }
1765
1766    #[test]
1767    fn decode_password_prompt_no_newline_with_cr() {
1768        // OpenVPN sends "ENTER PASSWORD:" without \n. Some builds may
1769        // include a trailing \r. The decoder must consume the \r and
1770        // still produce PasswordPrompt.
1771        let msgs = decode_all("ENTER PASSWORD:\r");
1772        assert_eq!(msgs.len(), 1);
1773        assert_eq!(msgs[0], OvpnMessage::PasswordPrompt);
1774    }
1775
1776    #[test]
1777    fn decode_password_prompt_with_cr_fully_consumes_buffer() {
1778        let mut codec = OvpnCodec::new();
1779        let mut buf = BytesMut::from("ENTER PASSWORD:\r");
1780        let msg = codec.decode(&mut buf).unwrap();
1781        assert_eq!(msg, Some(OvpnMessage::PasswordPrompt));
1782        assert!(
1783            buf.is_empty(),
1784            "trailing \\r was not consumed; {remaining} bytes remain",
1785            remaining = buf.len(),
1786        );
1787    }
1788
1789    #[test]
1790    fn decode_password_prompt_no_newline_without_cr() {
1791        let msgs = decode_all("ENTER PASSWORD:");
1792        assert_eq!(msgs.len(), 1);
1793        assert_eq!(msgs[0], OvpnMessage::PasswordPrompt);
1794    }
1795
1796    #[test]
1797    fn decode_password_notification() {
1798        let msgs = decode_all(">PASSWORD:Need 'Auth' username/password\n");
1799        assert_eq!(msgs.len(), 1);
1800        assert!(matches!(
1801            &msgs[0],
1802            OvpnMessage::Notification(Notification::Password(PasswordNotification::NeedAuth {
1803                auth_type: AuthType::Auth,
1804            }))
1805        ));
1806    }
1807
1808    #[test]
1809    fn escape_and_quote_special_chars() {
1810        assert_eq!(quote(&escape(r#"foo"bar"#)), r#""foo\"bar""#);
1811        assert_eq!(quote(&escape(r"a\b")), r#""a\\b""#);
1812        assert_eq!(quote(&escape("simple")), r#""simple""#);
1813    }
1814
1815    #[test]
1816    fn decode_empty_multiline() {
1817        // Some commands can return an empty multi-line block (just "END").
1818        let msgs = encode_then_decode(OvpnCommand::Status(StatusFormat::V1), "END\n");
1819        assert_eq!(msgs.len(), 1);
1820        assert!(matches!(&msgs[0], OvpnMessage::MultiLine(lines) if lines.is_empty()));
1821    }
1822
1823    #[test]
1824    fn decode_need_ok_notification() {
1825        let msgs = decode_all(
1826            ">NEED-OK:Need 'token-insertion-request' confirmation MSG:Please insert your token\n",
1827        );
1828        assert_eq!(msgs.len(), 1);
1829        assert!(matches!(
1830            &msgs[0],
1831            OvpnMessage::Notification(Notification::NeedOk { name, message })
1832                if name == "token-insertion-request" && message == "Please insert your token"
1833        ));
1834    }
1835
1836    #[test]
1837    fn decode_hold_notification() {
1838        let msgs = decode_all(">HOLD:Waiting for hold release\n");
1839        assert_eq!(msgs.len(), 1);
1840        assert!(matches!(
1841            &msgs[0],
1842            OvpnMessage::Notification(Notification::Hold { text })
1843                if text == "Waiting for hold release"
1844        ));
1845    }
1846
1847    // --- RawMultiLine tests ---
1848
1849    #[test]
1850    fn encode_raw_multiline() {
1851        assert_eq!(
1852            encode_to_string(OvpnCommand::RawMultiLine("custom-cmd arg".to_string())),
1853            "custom-cmd arg\n"
1854        );
1855    }
1856
1857    #[test]
1858    fn raw_multiline_expects_multiline_response() {
1859        let msgs = encode_then_decode(
1860            OvpnCommand::RawMultiLine("custom".to_string()),
1861            "line1\nline2\nEND\n",
1862        );
1863        assert_eq!(msgs.len(), 1);
1864        assert!(matches!(
1865            &msgs[0],
1866            OvpnMessage::MultiLine(lines) if lines == &["line1", "line2"]
1867        ));
1868    }
1869
1870    #[test]
1871    fn raw_multiline_sanitizes_newlines() {
1872        // Default mode is Sanitize — newlines are stripped.
1873        let wire = encode_to_string(OvpnCommand::RawMultiLine("cmd\ninjected".to_string()));
1874        assert_eq!(wire, "cmdinjected\n");
1875    }
1876
1877    #[test]
1878    fn raw_multiline_strict_rejects_newlines() {
1879        let mut codec = OvpnCodec::new().with_encoder_mode(EncoderMode::Strict);
1880        let mut buf = BytesMut::new();
1881        let result = codec.encode(
1882            OvpnCommand::RawMultiLine("cmd\ninjected".to_string()),
1883            &mut buf,
1884        );
1885        assert!(result.is_err());
1886    }
1887
1888    // --- Sequential encode/decode tests ---
1889
1890    #[test]
1891    #[traced_test]
1892    fn encode_during_multiline_accumulation_warns_but_succeeds() {
1893        let mut codec = OvpnCodec::new();
1894        let mut buf = BytesMut::new();
1895        // Encode a command that expects multi-line response.
1896        codec
1897            .encode(OvpnCommand::Status(StatusFormat::V1), &mut buf)
1898            .unwrap();
1899        // Feed partial multi-line response (no END yet).
1900        let mut dec = BytesMut::from("header line\n");
1901        codec.decode(&mut dec).unwrap(); // starts multi_line_buf accumulation
1902        // Encoding again while accumulating logs a warning but succeeds.
1903        codec.encode(OvpnCommand::Pid, &mut buf).unwrap();
1904        assert_eq!(
1905            codec.expected_queue.len(),
1906            2,
1907            "both pending: first mid-accumulation, second queued"
1908        );
1909        assert!(logs_contain("mid-accumulation"));
1910    }
1911
1912    #[test]
1913    #[traced_test]
1914    fn encode_during_client_notif_accumulation_warns_but_succeeds() {
1915        let mut codec = OvpnCodec::new();
1916        let mut buf = BytesMut::new();
1917        // Feed a CLIENT header — starts client_notification accumulation.
1918        let mut dec = BytesMut::from(">CLIENT:CONNECT,0,1\n");
1919        codec.decode(&mut dec).unwrap();
1920        // Encoding while client_notification is active logs a warning but succeeds.
1921        codec.encode(OvpnCommand::Pid, &mut buf).unwrap();
1922        assert_eq!(codec.expected_queue.len(), 1);
1923        assert!(logs_contain("mid-accumulation"));
1924    }
1925
1926    /// Sending two commands before any response arrives — the response
1927    /// kind queue ensures each response is decoded with the correct kind.
1928    #[test]
1929    fn pipelined_commands_decode_correctly() {
1930        let mut codec = OvpnCodec::new();
1931        let mut enc = BytesMut::new();
1932        // Encode two commands: Status (multi-line) then Pid (success/error).
1933        codec
1934            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1935            .unwrap();
1936        codec.encode(OvpnCommand::Pid, &mut enc).unwrap();
1937        assert_eq!(codec.expected_queue.len(), 2);
1938
1939        // Feed the Status multi-line response followed by the Pid response.
1940        let mut dec = BytesMut::from("TITLE\nheader\ndata\nEND\nSUCCESS: pid=42\n");
1941        let mut msgs = Vec::new();
1942        while let Some(msg) = codec.decode(&mut dec).unwrap() {
1943            msgs.push(msg);
1944        }
1945        assert_eq!(msgs.len(), 2);
1946        assert!(
1947            matches!(&msgs[0], OvpnMessage::MultiLine(lines) if lines == &["TITLE", "header", "data"]),
1948            "first response should be MultiLine, got {:?}",
1949            msgs[0]
1950        );
1951        assert!(
1952            matches!(&msgs[1], OvpnMessage::Success(s) if s == "pid=42"),
1953            "second response should be Success, got {:?}",
1954            msgs[1]
1955        );
1956        assert!(codec.expected_queue.is_empty());
1957    }
1958
1959    // --- Accumulation limit tests ---
1960
1961    #[test]
1962    fn default_accumulation_limit_allows_reasonable_responses() {
1963        let mut codec = OvpnCodec::new();
1964        let mut enc = BytesMut::new();
1965        codec
1966            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1967            .unwrap();
1968        // Feed 500 lines + END — well within the default Max(10_000).
1969        let mut data = String::new();
1970        for i in 0..500 {
1971            data.push_str(&format!("line {i}\n"));
1972        }
1973        data.push_str("END\n");
1974        let mut dec = BytesMut::from(data.as_str());
1975        let mut msgs = Vec::new();
1976        while let Some(msg) = codec.decode(&mut dec).unwrap() {
1977            msgs.push(msg);
1978        }
1979        assert_eq!(msgs.len(), 1);
1980        assert!(matches!(
1981            &msgs[0],
1982            OvpnMessage::MultiLine(lines) if lines.len() == 500
1983        ));
1984    }
1985
1986    #[test]
1987    fn multi_line_limit_exceeded() {
1988        let mut codec = OvpnCodec::new().with_max_multi_line_lines(AccumulationLimit::Max(3));
1989        let mut enc = BytesMut::new();
1990        codec
1991            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
1992            .unwrap();
1993        let mut dec = BytesMut::from("a\nb\nc\nd\nEND\n");
1994        let result = loop {
1995            match codec.decode(&mut dec) {
1996                Ok(Some(msg)) => break Ok(msg),
1997                Ok(None) => continue,
1998                Err(error) => break Err(error),
1999            }
2000        };
2001        assert!(result.is_err(), "expected error when limit exceeded");
2002        let err = result.unwrap_err();
2003        assert!(
2004            err.to_string().contains("multi-line response"),
2005            "error should mention multi-line: {err}"
2006        );
2007    }
2008
2009    #[test]
2010    fn multi_line_limit_exact_boundary_passes() {
2011        let mut codec = OvpnCodec::new().with_max_multi_line_lines(AccumulationLimit::Max(3));
2012        let mut enc = BytesMut::new();
2013        codec
2014            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
2015            .unwrap();
2016        // Exactly 3 lines should succeed.
2017        let mut dec = BytesMut::from("a\nb\nc\nEND\n");
2018        let mut msgs = Vec::new();
2019        while let Some(msg) = codec.decode(&mut dec).unwrap() {
2020            msgs.push(msg);
2021        }
2022        assert_eq!(msgs.len(), 1);
2023        assert!(matches!(
2024            &msgs[0],
2025            OvpnMessage::MultiLine(lines) if lines.len() == 3
2026        ));
2027    }
2028
2029    #[test]
2030    fn client_env_limit_exceeded() {
2031        let mut codec = OvpnCodec::new().with_max_client_env_entries(AccumulationLimit::Max(2));
2032        let mut dec = BytesMut::from(
2033            ">CLIENT:CONNECT,0,1\n\
2034             >CLIENT:ENV,a=1\n\
2035             >CLIENT:ENV,b=2\n\
2036             >CLIENT:ENV,c=3\n\
2037             >CLIENT:ENV,END\n",
2038        );
2039        let result = loop {
2040            match codec.decode(&mut dec) {
2041                Ok(Some(msg)) => break Ok(msg),
2042                Ok(None) => continue,
2043                Err(error) => break Err(error),
2044            }
2045        };
2046        assert!(
2047            result.is_err(),
2048            "expected error when client ENV limit exceeded"
2049        );
2050        let err = result.unwrap_err();
2051        assert!(
2052            err.to_string().contains("client ENV"),
2053            "error should mention client ENV: {err}"
2054        );
2055    }
2056
2057    // --- UTF-8 error state reset tests ---
2058
2059    #[test]
2060    fn utf8_error_resets_multiline_state() {
2061        let mut codec = OvpnCodec::new();
2062        let mut enc = BytesMut::new();
2063        codec
2064            .encode(OvpnCommand::Status(StatusFormat::V1), &mut enc)
2065            .unwrap();
2066        // Feed a valid first line to start multi-line accumulation.
2067        let mut dec = BytesMut::from("header\n");
2068        assert!(codec.decode(&mut dec).unwrap().is_none());
2069        // Feed invalid UTF-8.
2070        dec.extend_from_slice(b"bad \xff line\n");
2071        assert!(codec.decode(&mut dec).is_err());
2072        // State should be reset — next valid line should decode cleanly
2073        // as an Unrecognized (since expected was reset to SuccessOrError).
2074        dec.extend_from_slice(b"SUCCESS: recovered\n");
2075        let msg = codec
2076            .decode(&mut dec)
2077            .unwrap()
2078            .expect("should produce a message");
2079        assert!(
2080            matches!(&msg, OvpnMessage::Success(s) if s.contains("recovered")),
2081            "expected Success containing 'recovered', got {msg:?}"
2082        );
2083    }
2084
2085    #[test]
2086    fn utf8_error_resets_client_notif_state() {
2087        let mut codec = OvpnCodec::new();
2088        // Start CLIENT accumulation.
2089        let mut dec = BytesMut::from(">CLIENT:CONNECT,0,1\n");
2090        assert!(codec.decode(&mut dec).unwrap().is_none());
2091        // Feed invalid UTF-8 within the ENV block.
2092        dec.extend_from_slice(b">CLIENT:ENV,\xff\n");
2093        assert!(codec.decode(&mut dec).is_err());
2094        // State should be reset.
2095        dec.extend_from_slice(b"SUCCESS: ok\n");
2096        let msg = codec
2097            .decode(&mut dec)
2098            .unwrap()
2099            .expect("should produce a message");
2100        assert!(
2101            matches!(&msg, OvpnMessage::Success(_)),
2102            "expected Success after UTF-8 reset, got {msg:?}"
2103        );
2104    }
2105
2106    #[test]
2107    fn encode_set_version() {
2108        assert_eq!(encode_to_string(OvpnCommand::SetVersion(2)), "version 2\n");
2109        assert_eq!(encode_to_string(OvpnCommand::SetVersion(4)), "version 4\n");
2110    }
2111
2112    #[test]
2113    fn encode_remote_skip_n() {
2114        assert_eq!(
2115            encode_to_string(OvpnCommand::Remote(RemoteAction::SkipN(3))),
2116            "remote SKIP 3\n"
2117        );
2118    }
2119}