Skip to main content

openvpn_mgmt_codec/
codec.rs

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