Skip to main content

openvpn_mgmt_codec/
codec.rs

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