Skip to main content

openvpn_mgmt_codec/
command.rs

1use std::fmt;
2use std::str::FromStr;
3
4use crate::{
5    auth::{AuthRetryMode, AuthType, ParseAuthRetryModeError},
6    client_deny::ClientDeny,
7    kill_target::KillTarget,
8    need_ok::NeedOkResponse,
9    proxy_action::ProxyAction,
10    redacted::Redacted,
11    remote_action::RemoteAction,
12    signal::{ParseSignalError, Signal},
13    status_format::StatusFormat,
14    stream_mode::{ParseStreamModeError, StreamMode},
15    transport_protocol::TransportProtocol,
16};
17use tracing::warn;
18
19/// Extract the next token from a management-interface command line.
20///
21/// The OpenVPN management interface uses a simple lexer
22/// ([`manage.c` → `parse_line()`][parse_line]) that recognises
23/// double-quoted tokens with backslash escaping (`\\` → `\`, `\"` → `"`).
24/// Unquoted tokens are delimited by whitespace.
25///
26/// Returns `(token, rest)` where *token* has quotes/escapes resolved and
27/// *rest* is the remaining input (leading whitespace trimmed).
28///
29/// [parse_line]: https://github.com/OpenVPN/openvpn/blob/master/src/openvpn/manage.c
30///
31/// # Spec reference
32///
33/// Escaping rules: [`management-notes.txt`][spec], "Command Parsing" section.
34///
35/// [spec]: https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
36fn next_token(input: &str) -> Option<(String, &str)> {
37    let input = input.trim_start();
38    if input.is_empty() {
39        return None;
40    }
41
42    if let Some(quoted) = input.strip_prefix('"') {
43        // Quoted token: consume until unescaped closing quote.
44        let mut chars = quoted.chars();
45        let mut token = String::new();
46        let mut closed = false;
47        loop {
48            match chars.next() {
49                None => break,
50                Some('"') => {
51                    closed = true;
52                    break;
53                }
54                Some('\\') => match chars.next() {
55                    Some(escaped) => token.push(escaped),
56                    None => break,
57                },
58                Some(plain) => token.push(plain),
59            }
60        }
61        if !closed {
62            return None;
63        }
64        let rest = chars.as_str().trim_start();
65        Some((token, rest))
66    } else {
67        // Unquoted token: delimited by whitespace.
68        match input.split_once(char::is_whitespace) {
69            Some((tok, rest)) => Some((tok.to_string(), rest.trim_start())),
70            None => Some((input.to_string(), "")),
71        }
72    }
73}
74
75/// Error returned when parsing a command string fails.
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77pub enum CommandParseError {
78    /// Unrecognized signal name.
79    #[error(transparent)]
80    Signal(#[from] ParseSignalError),
81
82    /// Unrecognized stream mode.
83    #[error(transparent)]
84    StreamMode(#[from] ParseStreamModeError),
85
86    /// Unrecognized auth retry mode.
87    #[error(transparent)]
88    AuthRetryMode(#[from] ParseAuthRetryModeError),
89
90    /// A numeric argument could not be parsed.
91    #[error("{field} must be a number, got: {input}")]
92    InvalidNumber {
93        /// Which parameter was expected to be numeric.
94        field: &'static str,
95        /// The value that failed to parse.
96        input: String,
97    },
98
99    /// An argument value is not one of the accepted choices.
100    #[error("invalid {field}: {input} ({hint})")]
101    InvalidChoice {
102        /// Which parameter had an invalid value.
103        field: &'static str,
104        /// The rejected value.
105        input: String,
106        /// Human-readable description of valid choices.
107        hint: &'static str,
108    },
109
110    /// Missing or insufficient arguments.
111    #[error("{0}")]
112    MissingArgs(&'static str),
113}
114
115/// Range selector for `remote-entry-get`.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RemoteEntryRange {
118    /// A single entry by index.
119    Single(u32),
120
121    /// A range of entries `[from, end)`.
122    Range {
123        /// Start index (inclusive).
124        from: u32,
125        /// End index (exclusive).
126        end: u32,
127    },
128
129    /// All entries.
130    All,
131}
132
133impl fmt::Display for RemoteEntryRange {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::Single(index) => write!(f, "{index}"),
137            Self::Range { from, end } => write!(f, "{from} {end}"),
138            Self::All => f.write_str("all"),
139        }
140    }
141}
142
143/// Every command the management interface accepts, modeled as a typed enum.
144///
145/// The encoder handles all serialization — escaping, quoting, multi-line
146/// block framing — so callers never assemble raw strings. The `Raw` variant
147/// exists as an escape hatch for commands not yet modeled here.
148///
149/// Sensitive fields (passwords, tokens, challenge responses) are wrapped in
150/// [`Redacted`] so they are masked in [`Debug`] and [`Display`](std::fmt::Display)
151/// output. Use [`Redacted::expose`] to access the raw value for wire encoding.
152#[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)]
153#[strum(serialize_all = "kebab-case")]
154pub enum OvpnCommand {
155    // --- Informational ---
156    /// Request connection status in the given format.
157    /// Wire: `status` / `status 2` / `status 3`
158    Status(StatusFormat),
159
160    /// Print current state (single comma-delimited line).
161    /// Wire: `state`
162    State,
163
164    /// Control real-time state notifications and/or dump history.
165    /// Wire: `state on` / `state off` / `state all` / `state on all` / `state 3`
166    StateStream(StreamMode),
167
168    /// Print the OpenVPN and management interface version.
169    /// Wire: `version`
170    Version,
171
172    /// Set the management client version to announce feature support.
173    /// Wire: `version 2`
174    ///
175    /// Feature gates:
176    /// - version > 1: `>PK_SIGN:` notifications and `pk-sig` responses.
177    /// - version > 2: `>PK_SIGN:data,algorithm` includes the algorithm field.
178    /// - version >= 4: a `SUCCESS:` response is returned for the `version` command.
179    ///
180    /// **Note**: versions 1–3 produce no response; only version >= 4 returns
181    /// `SUCCESS:`. The codec tracks response expectations accordingly.
182    SetVersion(u32),
183
184    /// Show the PID of the OpenVPN process.
185    /// Wire: `pid`
186    Pid,
187
188    /// List available management commands.
189    /// Wire: `help`
190    Help,
191
192    /// Get or set the log verbosity level (0–15).
193    /// `Verb(None)` queries the current level; `Verb(Some(n))` sets it.
194    /// Wire: `verb` / `verb 4`
195    Verb(Option<u8>),
196
197    /// Get or set the mute threshold (suppress repeating messages).
198    /// Wire: `mute` / `mute 40`
199    Mute(Option<u32>),
200
201    /// (Windows only) Show network adapter list and routing table.
202    /// Wire: `net`
203    Net,
204
205    // --- Real-time notification control ---
206    /// Control real-time log streaming and/or dump log history.
207    /// Wire: `log on` / `log off` / `log all` / `log on all` / `log 20`
208    ///
209    /// # Pitfall
210    ///
211    /// At `verb 4` or above, [`StreamMode::All`] and [`StreamMode::OnAll`]
212    /// can produce an extremely large — or effectively unbounded — history
213    /// dump, because OpenVPN logs its own management I/O at that verbosity.
214    /// Prefer [`StreamMode::On`] (no history) or [`StreamMode::Recent`] to
215    /// cap the dump size.
216    Log(StreamMode),
217
218    /// Control real-time echo parameter notifications.
219    /// Wire: `echo on` / `echo off` / `echo all` / `echo on all`
220    Echo(StreamMode),
221
222    /// Enable/disable byte count notifications at N-second intervals.
223    /// Pass 0 to disable.
224    /// Wire: `bytecount 5` / `bytecount 0`
225    ByteCount(u32),
226
227    // --- Connection control ---
228    /// Send a signal to the OpenVPN daemon.
229    /// Wire: `signal SIGUSR1`
230    Signal(Signal),
231
232    /// Kill a specific client connection (server mode).
233    /// Wire: `kill Test-Client` / `kill 1.2.3.4:4000`
234    Kill(KillTarget),
235
236    /// Query the current hold flag.
237    /// Wire: `hold`
238    /// Response: `SUCCESS: hold=0` or `SUCCESS: hold=1`
239    HoldQuery,
240
241    /// Set the hold flag on — future restarts will pause until released.
242    /// Wire: `hold on`
243    HoldOn,
244
245    /// Clear the hold flag.
246    /// Wire: `hold off`
247    HoldOff,
248
249    /// Release from hold state and start OpenVPN. Does not change the
250    /// hold flag itself.
251    /// Wire: `hold release`
252    HoldRelease,
253
254    // --- Authentication ---
255    /// Supply a username for the given auth type.
256    /// Wire: `username "Auth" myuser`
257    Username {
258        /// Which credential set this username belongs to.
259        auth_type: AuthType,
260        /// The username value (redacted in debug output).
261        value: Redacted,
262    },
263
264    /// Supply a password for the given auth type. The value is escaped
265    /// and double-quoted per the OpenVPN config-file lexer rules.
266    /// Wire: `password "Private Key" "foo\"bar"`
267    Password {
268        /// Which credential set this password belongs to.
269        auth_type: AuthType,
270        /// The password value (redacted in debug output, escaped on the wire).
271        value: Redacted,
272    },
273
274    /// Set the auth-retry strategy.
275    /// Wire: `auth-retry interact`
276    AuthRetry(AuthRetryMode),
277
278    /// Forget all passwords entered during this management session.
279    /// Wire: `forget-passwords`
280    ForgetPasswords,
281
282    // --- Challenge-response authentication ---
283    /// Respond to a CRV1 dynamic challenge.
284    /// Wire: `password "Auth" "CRV1::state_id::response"`
285    ChallengeResponse {
286        /// The opaque state ID from the `>PASSWORD:` CRV1 notification.
287        state_id: String,
288        /// The user's response to the challenge (redacted in debug output).
289        response: Redacted,
290    },
291
292    /// Respond to a static challenge (SC).
293    /// Wire: `password "Auth" "SCRV1::base64_password::base64_response"`
294    ///
295    /// The caller must pre-encode password and response as base64 —
296    /// this crate does not include a base64 dependency.
297    StaticChallengeResponse {
298        /// Base64-encoded password (redacted in debug output).
299        password_b64: Redacted,
300        /// Base64-encoded challenge response (redacted in debug output).
301        response_b64: Redacted,
302    },
303
304    // --- Interactive prompts (OpenVPN 2.1+) ---
305    /// Respond to a `>NEED-OK:` prompt.
306    /// Wire: `needok token-insertion-request ok` / `needok ... cancel`
307    NeedOk {
308        /// The prompt name from the `>NEED-OK:` notification.
309        name: String,
310        /// Accept or cancel.
311        response: NeedOkResponse,
312    },
313
314    /// Respond to a `>NEED-STR:` prompt with a string value.
315    /// Wire: `needstr name "John"`
316    NeedStr {
317        /// The prompt name from the `>NEED-STR:` notification.
318        name: String,
319        /// The string value to send (will be escaped on the wire).
320        value: String,
321    },
322
323    // --- PKCS#11 (OpenVPN 2.1+) ---
324    /// Query available PKCS#11 certificate count.
325    /// Wire: `pkcs11-id-count`
326    Pkcs11IdCount,
327
328    /// Retrieve a PKCS#11 certificate by index.
329    /// Wire: `pkcs11-id-get 1`
330    Pkcs11IdGet(u32),
331
332    // --- External key / RSA signature (OpenVPN 2.3+) ---
333    /// Provide an RSA signature in response to `>RSA_SIGN:`.
334    /// This is a multi-line command: the encoder writes `rsa-sig`,
335    /// then each base64 line, then `END`.
336    RsaSig {
337        /// Base64-encoded signature lines.
338        base64_lines: Vec<String>,
339    },
340
341    // --- Client management (server mode, OpenVPN 2.1+) ---
342    /// Authorize a `>CLIENT:CONNECT` or `>CLIENT:REAUTH` and push config
343    /// directives. Multi-line command: header, config lines, `END`.
344    /// An empty `config_lines` produces a null block (header + immediate END),
345    /// which is equivalent to `client-auth-nt` in effect.
346    ClientAuth {
347        /// Client ID from the `>CLIENT:` notification.
348        cid: u64,
349        /// Key ID from the `>CLIENT:` notification.
350        kid: u64,
351        /// Config directives to push (e.g. `push "route ..."`).
352        config_lines: Vec<String>,
353    },
354
355    /// Authorize a client without pushing any config.
356    /// Wire: `client-auth-nt {CID} {KID}`
357    ClientAuthNt {
358        /// Client ID.
359        cid: u64,
360        /// Key ID.
361        kid: u64,
362    },
363
364    /// Deny a `>CLIENT:CONNECT` or `>CLIENT:REAUTH`.
365    /// Wire: `client-deny {CID} {KID} "reason" ["client-reason"]`
366    ClientDeny(ClientDeny),
367
368    /// Kill a client session by CID, optionally with a custom message.
369    /// Wire: `client-kill {CID}` or `client-kill {CID} {message}`
370    /// Default message is `RESTART` if omitted.
371    ClientKill {
372        /// Client ID.
373        cid: u64,
374        /// Optional kill message (e.g. `"HALT"`, `"RESTART"`). Defaults to
375        /// `RESTART` on the server if `None`.
376        message: Option<String>,
377    },
378
379    // --- Remote/Proxy override ---
380    /// Respond to a `>REMOTE:` notification (requires `--management-query-remote`).
381    /// Wire: `remote ACCEPT` / `remote SKIP` / `remote MOD host port`
382    Remote(RemoteAction),
383
384    /// Respond to a `>PROXY:` notification (requires `--management-query-proxy`).
385    /// Wire: `proxy NONE` / `proxy HTTP host port [nct]` / `proxy SOCKS host port`
386    Proxy(ProxyAction),
387
388    // --- Server statistics ---
389    /// Request aggregated server stats.
390    /// Wire: `load-stats`
391    /// Response: `SUCCESS: nclients=N,bytesin=N,bytesout=N`
392    LoadStats,
393
394    // --- Extended client management (OpenVPN 2.5+) ---
395    /// Defer authentication for a client, allowing async auth backends.
396    /// Wire: `client-pending-auth {CID} {KID} {EXTRA} {TIMEOUT}`
397    ClientPendingAuth {
398        /// Client ID.
399        cid: u64,
400        /// Key ID.
401        kid: u64,
402        /// Extra opaque string passed to the auth backend.
403        extra: String,
404        /// Timeout in seconds before the pending auth expires.
405        timeout: u32,
406    },
407
408    /// Respond to a CR_TEXT challenge (client-side, OpenVPN 2.6+).
409    /// Wire: `cr-response {base64-response}`
410    CrResponse {
411        /// The base64-encoded challenge-response answer (redacted in debug output).
412        response: Redacted,
413    },
414
415    // --- External key signature (OpenVPN 2.5+, management v2+) ---
416    /// Provide a signature in response to `>PK_SIGN:`. Replacement for
417    /// `rsa-sig` that supports ECDSA, RSA-PSS, and other key types.
418    /// Multi-line command: `pk-sig`, base64 lines, `END`.
419    PkSig {
420        /// Base64-encoded signature lines.
421        base64_lines: Vec<String>,
422    },
423
424    // --- ENV filter (OpenVPN 2.6+) ---
425    /// Set the env-var filter level for `>CLIENT:ENV` blocks.
426    /// Level 0 = all vars, higher levels filter more.
427    /// Wire: `env-filter [level]`
428    /// Response: `SUCCESS: env_filter_level=N`
429    EnvFilter(u32),
430
431    // --- Remote entry queries (management v3+) ---
432    /// Query the number of `--remote` entries configured.
433    /// Wire: `remote-entry-count`
434    /// Response: multi-line (count, then `END`).
435    RemoteEntryCount,
436
437    /// Retrieve `--remote` entries by index or all at once.
438    /// Wire: `remote-entry-get i|all [j]`
439    /// Response: multi-line (`index,remote_string` per line, then `END`).
440    RemoteEntryGet(RemoteEntryRange),
441
442    // --- Push updates (OpenVPN 2.7+, server mode) ---
443    /// Broadcast a push option update to all connected clients.
444    /// Wire: `push-update-broad "options"`
445    PushUpdateBroad {
446        /// Quoted options string (e.g. `"route 10.0.0.0, -dns"`).
447        options: String,
448    },
449
450    /// Push an option update to a specific client by CID.
451    /// Wire: `push-update-cid CID "options"`
452    PushUpdateCid {
453        /// Client ID.
454        cid: u64,
455        /// Quoted options string.
456        options: String,
457    },
458
459    // --- External certificate (OpenVPN 2.4+) ---
460    /// Supply an external certificate in response to `>NEED-CERTIFICATE`.
461    /// Multi-line command: header, PEM lines, `END`.
462    /// Wire: `certificate\n{pem_lines}\nEND`
463    Certificate {
464        /// PEM-encoded certificate lines.
465        pem_lines: Vec<String>,
466    },
467
468    // --- Management interface authentication ---
469    /// Authenticate to the management interface itself. Sent as a bare
470    /// line (no command prefix, no quoting) in response to
471    /// [`crate::OvpnMessage::PasswordPrompt`].
472    /// Wire: `{password}\n`
473    ManagementPassword(Redacted),
474
475    // --- Session lifecycle ---
476    /// Close the management session. OpenVPN keeps running and resumes
477    /// listening for new management connections.
478    Exit,
479
480    /// Identical to `Exit`.
481    Quit,
482
483    // --- Escape hatch ---
484    /// Send a raw command string for anything not yet modeled above.
485    /// The decoder expects a `SUCCESS:`/`ERROR:` response.
486    Raw(String),
487
488    /// Send a raw command string, expecting a multi-line (END-terminated)
489    /// response.
490    ///
491    /// Like [`Raw`](Self::Raw), the string is passed through the encoder's
492    /// wire-safety gate before sending (see [`crate::EncoderMode`]). Unlike
493    /// `Raw`, the decoder accumulates the response into
494    /// [`OvpnMessage::MultiLine`](crate::OvpnMessage::MultiLine).
495    RawMultiLine(String),
496}
497
498/// What kind of response the decoder should expect after a given command.
499/// This is the core of the command-tracking mechanism that resolves the
500/// protocol's ambiguity around single-line vs. multi-line responses.
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub(crate) enum ResponseKind {
503    /// Expect a `SUCCESS:` or `ERROR:` line.
504    SuccessOrError,
505
506    /// Expect multiple lines terminated by a bare `END`.
507    MultiLine,
508
509    /// No response expected (connection may close).
510    NoResponse,
511}
512
513impl OvpnCommand {
514    /// Determine what kind of response this command produces, so the
515    /// decoder knows how to frame the next incoming bytes.
516    pub(crate) fn expected_response(&self) -> ResponseKind {
517        match self {
518            // These always produce multi-line (END-terminated) responses.
519            Self::Status(_)
520            | Self::Version
521            | Self::Help
522            | Self::Net
523            | Self::RemoteEntryCount
524            | Self::RemoteEntryGet(_) => ResponseKind::MultiLine,
525
526            // state/log/echo: depends on the specific sub-mode.
527            Self::StateStream(mode) | Self::Log(mode) | Self::Echo(mode) => match mode {
528                StreamMode::All | StreamMode::OnAll | StreamMode::Recent(_) => {
529                    ResponseKind::MultiLine
530                }
531                StreamMode::On | StreamMode::Off => ResponseKind::SuccessOrError,
532            },
533
534            // Bare `state` returns state history (END-terminated).
535            Self::State => ResponseKind::MultiLine,
536
537            // Raw multi-line expects END-terminated response.
538            Self::RawMultiLine(_) => ResponseKind::MultiLine,
539
540            // exit/quit close the connection.
541            Self::Exit | Self::Quit => ResponseKind::NoResponse,
542
543            // `version N` for N < 4 produces no response; N >= 4 returns SUCCESS:.
544            Self::SetVersion(n) if *n < 4 => ResponseKind::NoResponse,
545
546            // Everything else (including Raw) produces SUCCESS: or ERROR:.
547            _ => ResponseKind::SuccessOrError,
548        }
549    }
550}
551
552impl FromStr for OvpnCommand {
553    type Err = CommandParseError;
554
555    /// Parse a human-readable command string into an [`OvpnCommand`].
556    ///
557    /// This accepts the same syntax used by interactive management clients:
558    /// a command name followed by space-separated arguments.
559    ///
560    /// Commands that cannot be represented as a single line (multi-line bodies
561    /// like `rsa-sig`, `client-auth` config lines, `certificate` PEM) are
562    /// parsed with comma-separated lines in the argument position.
563    ///
564    /// Unrecognized commands fall through to [`OvpnCommand::Raw`].
565    ///
566    /// # Examples
567    ///
568    /// ```
569    /// use openvpn_mgmt_codec::OvpnCommand;
570    ///
571    /// let cmd: OvpnCommand = "version".parse().unwrap();
572    /// assert_eq!(cmd, OvpnCommand::Version);
573    ///
574    /// let cmd: OvpnCommand = "state on all".parse().unwrap();
575    /// assert_eq!(cmd, OvpnCommand::StateStream(openvpn_mgmt_codec::StreamMode::OnAll));
576    /// ```
577    fn from_str(line: &str) -> Result<Self, Self::Err> {
578        /// Shorthand for `Err(CommandParseError::MissingArgs(...))`.
579        fn cmd_err<T>(msg: &'static str) -> Result<T, CommandParseError> {
580            Err(CommandParseError::MissingArgs(msg))
581        }
582
583        let line = line.trim();
584        let (cmd, args) = line
585            .split_once(char::is_whitespace)
586            .map(|(c, a)| (c, a.trim()))
587            .unwrap_or((line, ""));
588
589        match cmd {
590            // --- Informational ---
591            "version" if args.is_empty() => Ok(Self::Version),
592            "version" => Ok(Self::SetVersion(args.parse().map_err(|_| {
593                CommandParseError::InvalidChoice {
594                    field: "version number",
595                    input: args.to_string(),
596                    hint: "expected a positive integer (e.g. version 2)",
597                }
598            })?)),
599            "pid" => Ok(Self::Pid),
600            "help" => Ok(Self::Help),
601            "net" => Ok(Self::Net),
602            "load-stats" => Ok(Self::LoadStats),
603
604            "status" => match args {
605                "" | "1" => Ok(Self::Status(StatusFormat::V1)),
606                "2" => Ok(Self::Status(StatusFormat::V2)),
607                "3" => Ok(Self::Status(StatusFormat::V3)),
608                _ => Err(CommandParseError::InvalidChoice {
609                    field: "status format",
610                    input: args.to_string(),
611                    hint: "use 1, 2, or 3",
612                }),
613            },
614
615            "state" => match args {
616                "" => Ok(Self::State),
617                other => Ok(Self::StateStream(other.parse::<StreamMode>()?)),
618            },
619
620            "log" => Ok(Self::Log(args.parse::<StreamMode>()?)),
621            "echo" => Ok(Self::Echo(args.parse::<StreamMode>()?)),
622
623            "verb" => {
624                if args.is_empty() {
625                    Ok(Self::Verb(None))
626                } else {
627                    args.parse::<u8>()
628                        .map(|level| Self::Verb(Some(level)))
629                        .map_err(|_| CommandParseError::InvalidNumber {
630                            field: "verbosity",
631                            input: args.to_string(),
632                        })
633                }
634            }
635
636            "mute" => {
637                if args.is_empty() {
638                    Ok(Self::Mute(None))
639                } else {
640                    args.parse::<u32>()
641                        .map(|threshold| Self::Mute(Some(threshold)))
642                        .map_err(|_| CommandParseError::InvalidNumber {
643                            field: "mute threshold",
644                            input: args.to_string(),
645                        })
646                }
647            }
648
649            "bytecount" => args.parse::<u32>().map(Self::ByteCount).map_err(|_| {
650                CommandParseError::InvalidNumber {
651                    field: "bytecount interval",
652                    input: args.to_string(),
653                }
654            }),
655
656            // --- Connection control ---
657            "signal" => Ok(Self::Signal(args.parse::<Signal>()?)),
658
659            "kill" => {
660                if args.is_empty() {
661                    return cmd_err("kill requires a target (common name or proto:ip:port)");
662                }
663                let parts: Vec<&str> = args.splitn(3, ':').collect();
664                if parts.len() == 3
665                    && let Ok(port) = parts[2].parse::<u16>()
666                {
667                    return Ok(Self::Kill(KillTarget::Address {
668                        protocol: parts[0]
669                            .parse()
670                            .inspect_err(|error| warn!(%error, "unknown transport protocol"))
671                            .unwrap_or_else(|_| TransportProtocol::Unknown(parts[0].to_string())),
672                        ip: parts[1].to_string(),
673                        port,
674                    }));
675                }
676                Ok(Self::Kill(KillTarget::CommonName(args.to_string())))
677            }
678
679            "hold" => match args {
680                "" => Ok(Self::HoldQuery),
681                "on" => Ok(Self::HoldOn),
682                "off" => Ok(Self::HoldOff),
683                "release" => Ok(Self::HoldRelease),
684                _ => Err(CommandParseError::InvalidChoice {
685                    field: "hold argument",
686                    input: args.to_string(),
687                    hint: "use on, off, or release",
688                }),
689            },
690
691            // --- Authentication ---
692            // Wire format per management-notes.txt:
693            //   username "Auth" myuser
694            //   password "Private Key" "foo\"bar"
695            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
696            //
697            // Auth types are quoted and may contain spaces ("HTTP Proxy",
698            // "SOCKS Proxy", "Private Key"), so we use quote-aware
699            // tokenization matching manage.c's parse_line() lexer.
700            // https://github.com/OpenVPN/openvpn/blob/master/src/openvpn/manage.c
701            "username" => {
702                let (auth_type_str, rest) = next_token(args).ok_or(
703                    CommandParseError::MissingArgs("usage: username <auth-type> <value>"),
704                )?;
705                let (value, _) = next_token(rest).ok_or(CommandParseError::MissingArgs(
706                    "usage: username <auth-type> <value>",
707                ))?;
708                Ok(Self::Username {
709                    auth_type: auth_type_str
710                        .parse()
711                        .inspect_err(|error| warn!(%error, "unknown auth type"))
712                        .unwrap_or(AuthType::Unknown(auth_type_str)),
713                    value: value.into(),
714                })
715            }
716
717            "password" => {
718                let (auth_type_str, rest) = next_token(args).ok_or(
719                    CommandParseError::MissingArgs("usage: password <auth-type> <value>"),
720                )?;
721                let (value, _) = next_token(rest).ok_or(CommandParseError::MissingArgs(
722                    "usage: password <auth-type> <value>",
723                ))?;
724                Ok(Self::Password {
725                    auth_type: auth_type_str
726                        .parse()
727                        .inspect_err(|error| warn!(%error, "unknown auth type"))
728                        .unwrap_or(AuthType::Unknown(auth_type_str)),
729                    value: value.into(),
730                })
731            }
732
733            "auth-retry" => Ok(Self::AuthRetry(args.parse::<AuthRetryMode>()?)),
734
735            "forget-passwords" => Ok(Self::ForgetPasswords),
736
737            // --- Interactive prompts ---
738            "needok" => {
739                let (name, resp) =
740                    args.rsplit_once(char::is_whitespace)
741                        .ok_or(CommandParseError::MissingArgs(
742                            "usage: needok <name> ok|cancel",
743                        ))?;
744                let response = match resp {
745                    "ok" => NeedOkResponse::Ok,
746                    "cancel" => NeedOkResponse::Cancel,
747                    _ => {
748                        return Err(CommandParseError::InvalidChoice {
749                            field: "needok response",
750                            input: resp.to_string(),
751                            hint: "use ok or cancel",
752                        });
753                    }
754                };
755                Ok(Self::NeedOk {
756                    name: name.trim().to_string(),
757                    response,
758                })
759            }
760
761            // Wire format per management-notes.txt:
762            //   needstr <name> <value>
763            // where <value> may be quoted+escaped.
764            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
765            "needstr" => {
766                let (name, rest) = next_token(args).ok_or(CommandParseError::MissingArgs(
767                    "usage: needstr <name> <value>",
768                ))?;
769                let (value, _) = next_token(rest).ok_or(CommandParseError::MissingArgs(
770                    "usage: needstr <name> <value>",
771                ))?;
772                Ok(Self::NeedStr { name, value })
773            }
774
775            // --- PKCS#11 ---
776            "pkcs11-id-count" => Ok(Self::Pkcs11IdCount),
777
778            "pkcs11-id-get" => args.parse::<u32>().map(Self::Pkcs11IdGet).map_err(|_| {
779                CommandParseError::InvalidNumber {
780                    field: "pkcs11-id-get index",
781                    input: args.to_string(),
782                }
783            }),
784
785            // --- Client management (server mode) ---
786            "client-auth" => {
787                let mut parts = args.splitn(3, char::is_whitespace);
788                let cid = parts
789                    .next()
790                    .ok_or(CommandParseError::MissingArgs(
791                        "usage: client-auth <cid> <kid> [config-lines]",
792                    ))?
793                    .parse::<u64>()
794                    .map_err(|_| CommandParseError::MissingArgs("cid must be a number"))?;
795                let kid = parts
796                    .next()
797                    .ok_or(CommandParseError::MissingArgs(
798                        "usage: client-auth <cid> <kid> [config-lines]",
799                    ))?
800                    .parse::<u64>()
801                    .map_err(|_| CommandParseError::MissingArgs("kid must be a number"))?;
802                let config_lines = match parts.next() {
803                    Some(rest) => rest
804                        .split(',')
805                        .map(|line| line.trim().to_string())
806                        .collect(),
807                    None => vec![],
808                };
809                Ok(Self::ClientAuth {
810                    cid,
811                    kid,
812                    config_lines,
813                })
814            }
815
816            "client-auth-nt" => {
817                let (cid_s, kid_s) =
818                    args.split_once(char::is_whitespace)
819                        .ok_or(CommandParseError::MissingArgs(
820                            "usage: client-auth-nt <cid> <kid>",
821                        ))?;
822                Ok(Self::ClientAuthNt {
823                    cid: cid_s
824                        .parse()
825                        .map_err(|_| CommandParseError::MissingArgs("cid must be a number"))?,
826                    kid: kid_s
827                        .trim()
828                        .parse()
829                        .map_err(|_| CommandParseError::MissingArgs("kid must be a number"))?,
830                })
831            }
832
833            // Wire format per management-notes.txt:
834            //   client-deny <cid> <kid> "reason" ["client-reason"]
835            // Reason strings are quoted+escaped on the wire.
836            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
837            "client-deny" => {
838                let (cid_str, rest) = next_token(args).ok_or(CommandParseError::MissingArgs(
839                    "usage: client-deny <cid> <kid> <reason> [client-reason]",
840                ))?;
841                let cid = cid_str
842                    .parse::<u64>()
843                    .map_err(|_| CommandParseError::MissingArgs("cid must be a number"))?;
844                let (kid_str, rest) = next_token(rest).ok_or(CommandParseError::MissingArgs(
845                    "usage: client-deny <cid> <kid> <reason> [client-reason]",
846                ))?;
847                let kid = kid_str
848                    .parse::<u64>()
849                    .map_err(|_| CommandParseError::MissingArgs("kid must be a number"))?;
850                let (reason, rest) = next_token(rest).ok_or(CommandParseError::MissingArgs(
851                    "usage: client-deny <cid> <kid> <reason> [client-reason]",
852                ))?;
853                let client_reason = next_token(rest).map(|(cr, _)| cr);
854                Ok(Self::ClientDeny(ClientDeny {
855                    cid,
856                    kid,
857                    reason,
858                    client_reason,
859                }))
860            }
861
862            "client-kill" => {
863                let (cid_str, message) = match args.split_once(char::is_whitespace) {
864                    Some((c, m)) => (c, Some(m.trim().to_string())),
865                    None => (args, None),
866                };
867                let cid = cid_str
868                    .parse::<u64>()
869                    .map_err(|_| CommandParseError::InvalidNumber {
870                        field: "client-kill CID",
871                        input: cid_str.to_string(),
872                    })?;
873                Ok(Self::ClientKill { cid, message })
874            }
875
876            // --- Remote/Proxy override ---
877            "remote" => match args.split_whitespace().collect::<Vec<_>>().as_slice() {
878                ["accept" | "ACCEPT"] => Ok(Self::Remote(RemoteAction::Accept)),
879                ["skip" | "SKIP"] => Ok(Self::Remote(RemoteAction::Skip)),
880                ["skip" | "SKIP", n] => Ok(Self::Remote(RemoteAction::SkipN(n.parse().map_err(
881                    |_| CommandParseError::InvalidChoice {
882                        field: "remote skip count",
883                        input: n.to_string(),
884                        hint: "expected a positive integer (e.g. remote SKIP 3)",
885                    },
886                )?))),
887                ["mod" | "MOD", host, port] => Ok(Self::Remote(RemoteAction::Modify {
888                    host: host.to_string(),
889                    port: port
890                        .parse()
891                        .map_err(|_| CommandParseError::MissingArgs("port must be a number"))?,
892                })),
893                _ => cmd_err("usage: remote accept|skip [n]|mod <host> <port>"),
894            },
895
896            "proxy" => match args.split_whitespace().collect::<Vec<_>>().as_slice() {
897                ["none" | "NONE"] => Ok(Self::Proxy(ProxyAction::None)),
898                ["http" | "HTTP", host, port] => Ok(Self::Proxy(ProxyAction::Http {
899                    host: host.to_string(),
900                    port: port
901                        .parse()
902                        .map_err(|_| CommandParseError::MissingArgs("port must be a number"))?,
903                    non_cleartext_only: false,
904                })),
905                ["http" | "HTTP", host, port, "nct"] => Ok(Self::Proxy(ProxyAction::Http {
906                    host: host.to_string(),
907                    port: port
908                        .parse()
909                        .map_err(|_| CommandParseError::MissingArgs("port must be a number"))?,
910                    non_cleartext_only: true,
911                })),
912                ["socks" | "SOCKS", host, port] => Ok(Self::Proxy(ProxyAction::Socks {
913                    host: host.to_string(),
914                    port: port
915                        .parse()
916                        .map_err(|_| CommandParseError::MissingArgs("port must be a number"))?,
917                })),
918                _ => cmd_err("usage: proxy none|http <host> <port> [nct]|socks <host> <port>"),
919            },
920
921            // --- ENV filter ---
922            "env-filter" => {
923                let level = if args.is_empty() {
924                    0
925                } else {
926                    args.parse::<u32>()
927                        .map_err(|_| CommandParseError::InvalidNumber {
928                            field: "env-filter level",
929                            input: args.to_string(),
930                        })?
931                };
932                Ok(Self::EnvFilter(level))
933            }
934
935            // --- Remote entry queries ---
936            "remote-entry-count" => Ok(Self::RemoteEntryCount),
937
938            "remote-entry-get" => {
939                if args.is_empty() {
940                    return cmd_err("usage: remote-entry-get i|all [j]");
941                }
942                let range = if args == "all" {
943                    RemoteEntryRange::All
944                } else {
945                    let mut parts = args.splitn(2, char::is_whitespace);
946                    let from = parts.next().unwrap().parse::<u32>().map_err(|_| {
947                        CommandParseError::InvalidNumber {
948                            field: "remote-entry-get index",
949                            input: args.to_string(),
950                        }
951                    })?;
952                    match parts.next() {
953                        Some(to_str) => {
954                            let end = to_str.trim().parse::<u32>().map_err(|_| {
955                                CommandParseError::InvalidNumber {
956                                    field: "remote-entry-get end index",
957                                    input: to_str.to_string(),
958                                }
959                            })?;
960                            RemoteEntryRange::Range { from, end }
961                        }
962                        None => RemoteEntryRange::Single(from),
963                    }
964                };
965                Ok(Self::RemoteEntryGet(range))
966            }
967
968            // --- Push updates ---
969            // Wire format per management-notes.txt:
970            //   push-update-broad "options"
971            //   push-update-cid <cid> "options"
972            // Options are quoted+escaped on the wire.
973            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
974            "push-update-broad" => {
975                let (options, _) = next_token(args).ok_or(CommandParseError::MissingArgs(
976                    "usage: push-update-broad <options>",
977                ))?;
978                Ok(Self::PushUpdateBroad { options })
979            }
980
981            "push-update-cid" => {
982                let (cid_str, rest) = next_token(args).ok_or(CommandParseError::MissingArgs(
983                    "usage: push-update-cid <cid> <options>",
984                ))?;
985                let cid = cid_str.parse::<u64>().map_err(|_| {
986                    CommandParseError::MissingArgs("push-update-cid: cid must be a number")
987                })?;
988                let (options, _) = next_token(rest).ok_or(CommandParseError::MissingArgs(
989                    "usage: push-update-cid <cid> <options>",
990                ))?;
991                Ok(Self::PushUpdateCid { cid, options })
992            }
993
994            // --- Extended client management ---
995            // Wire format per management-notes.txt:
996            //   client-pending-auth <cid> <kid> <extra> <timeout>
997            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
998            "client-pending-auth" => {
999                let (cid_str, rest) = next_token(args).ok_or(CommandParseError::MissingArgs(
1000                    "usage: client-pending-auth <cid> <kid> <extra> <timeout>",
1001                ))?;
1002                let cid = cid_str
1003                    .parse::<u64>()
1004                    .map_err(|_| CommandParseError::MissingArgs("cid must be a number"))?;
1005                let (kid_str, rest) = next_token(rest).ok_or(CommandParseError::MissingArgs(
1006                    "usage: client-pending-auth <cid> <kid> <extra> <timeout>",
1007                ))?;
1008                let kid = kid_str
1009                    .parse::<u64>()
1010                    .map_err(|_| CommandParseError::MissingArgs("kid must be a number"))?;
1011                let (extra, rest) = next_token(rest).ok_or(CommandParseError::MissingArgs(
1012                    "usage: client-pending-auth <cid> <kid> <extra> <timeout>",
1013                ))?;
1014                let (timeout_str, _) = next_token(rest).ok_or(CommandParseError::MissingArgs(
1015                    "usage: client-pending-auth <cid> <kid> <extra> <timeout>",
1016                ))?;
1017                let timeout =
1018                    timeout_str
1019                        .parse::<u32>()
1020                        .map_err(|_| CommandParseError::InvalidNumber {
1021                            field: "client-pending-auth timeout",
1022                            input: timeout_str,
1023                        })?;
1024                Ok(Self::ClientPendingAuth {
1025                    cid,
1026                    kid,
1027                    extra,
1028                    timeout,
1029                })
1030            }
1031
1032            // Wire format per management-notes.txt:
1033            //   cr-response <base64-response>
1034            // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
1035            "cr-response" => {
1036                let (response, _) = next_token(args).ok_or(CommandParseError::MissingArgs(
1037                    "usage: cr-response <response>",
1038                ))?;
1039                Ok(Self::CrResponse {
1040                    response: Redacted::new(response),
1041                })
1042            }
1043
1044            // --- Raw multi-line ---
1045            "raw-ml" => {
1046                if args.is_empty() {
1047                    return cmd_err("usage: raw-ml <command>");
1048                }
1049                Ok(Self::RawMultiLine(args.to_string()))
1050            }
1051
1052            // --- Lifecycle ---
1053            "exit" => Ok(Self::Exit),
1054            "quit" => Ok(Self::Quit),
1055
1056            // --- Fallback: send as raw command ---
1057            _ => Ok(Self::Raw(line.to_string())),
1058        }
1059    }
1060}
1061
1062/// The standard startup sequence that most management clients send after
1063/// connecting.
1064///
1065/// This is the pattern used by `node-openvpn` and other clients: enable
1066/// log streaming, request the PID, start byte-count notifications, and
1067/// release the hold so OpenVPN begins connecting.
1068///
1069/// # Arguments
1070///
1071/// * `bytecount_interval` — seconds between `>BYTECOUNT:` notifications
1072///   (pass `0` to skip enabling byte counts).
1073///
1074/// # Initial state
1075///
1076/// The sequence uses [`StreamMode::OnAll`] for log and state, which
1077/// enables real-time streaming **and** dumps the history buffer as a
1078/// multi-line response. The state history response contains the current
1079/// state as its last entry — use
1080/// [`parse_current_state`](crate::parsed_response::parse_current_state)
1081/// to extract it. Do **not** rely solely on `>STATE:` notifications for
1082/// the initial state, because notifications only fire on *transitions*.
1083///
1084/// # Pitfall: `log on all` at high verbosity
1085///
1086/// At `verb 4` or above, the log history dump from `log on all` can be
1087/// extremely large (OpenVPN logs its own management I/O at that level).
1088/// The dump may grow faster than it drains, effectively hanging the
1089/// multi-line accumulation. Prefer [`StreamMode::On`] (no history) at
1090/// high verbosity, or use [`StreamMode::Recent`] to cap the dump size.
1091///
1092/// # Notification interleaving
1093///
1094/// The commands produce a mix of multi-line and single-line responses.
1095/// Asynchronous notifications (`>STATE:`, `>LOG:`, `>HOLD:`, etc.) can
1096/// arrive between any command and its response. The codec handles this
1097/// transparently, but consumers reading from the stream must handle
1098/// [`OvpnMessage::Notification`](crate::OvpnMessage::Notification) variants at any point.
1099///
1100/// # Examples
1101///
1102/// ```
1103/// use openvpn_mgmt_codec::command::connection_sequence;
1104/// use openvpn_mgmt_codec::OvpnCommand;
1105///
1106/// let cmds = connection_sequence(5);
1107/// assert!(cmds.iter().any(|cmd| matches!(cmd, OvpnCommand::HoldRelease)));
1108/// ```
1109///
1110/// To send these over a framed connection:
1111///
1112/// ```no_run
1113/// # async fn example() -> anyhow::Result<()> {
1114/// use tokio::net::TcpStream;
1115/// use tokio_util::codec::Framed;
1116/// use futures::SinkExt;
1117/// use openvpn_mgmt_codec::{OvpnCodec, OvpnCommand};
1118/// use openvpn_mgmt_codec::command::connection_sequence;
1119///
1120/// let stream = TcpStream::connect("127.0.0.1:7505").await?;
1121/// let mut framed = Framed::new(stream, OvpnCodec::new());
1122///
1123/// for cmd in connection_sequence(5) {
1124///     framed.send(cmd).await?;
1125/// }
1126/// # Ok(())
1127/// # }
1128/// ```
1129pub fn connection_sequence(bytecount_interval: u32) -> Vec<OvpnCommand> {
1130    let mut cmds = vec![
1131        OvpnCommand::Log(StreamMode::OnAll),
1132        OvpnCommand::StateStream(StreamMode::OnAll),
1133        OvpnCommand::Pid,
1134    ];
1135    if bytecount_interval > 0 {
1136        cmds.push(OvpnCommand::ByteCount(bytecount_interval));
1137    }
1138    cmds.push(OvpnCommand::HoldRelease);
1139    cmds
1140}
1141
1142/// Standard startup sequence for **server-mode** management clients.
1143///
1144/// Returns the commands that a management program typically sends when
1145/// OpenVPN connects with `--management-client`. This covers the same
1146/// basics as [`connection_sequence`] (log/state streaming, PID, bytecount,
1147/// hold release) and additionally sets the `>CLIENT:ENV` filter level so
1148/// that client notifications include the desired set of environment
1149/// variables.
1150///
1151/// # Arguments
1152///
1153/// * `bytecount_interval` — seconds between `>BYTECOUNT_CLI:` notifications
1154///   (0 to disable).
1155/// * `env_filter` — ENV filter level for `>CLIENT:ENV` blocks. Level 0
1156///   sends all variables; higher levels progressively filter.
1157///
1158/// # Example
1159///
1160/// ```
1161/// use openvpn_mgmt_codec::command::server_connection_sequence;
1162/// use openvpn_mgmt_codec::OvpnCommand;
1163///
1164/// let cmds = server_connection_sequence(5, 0);
1165///
1166/// // Contains env-filter for server-mode client notifications.
1167/// assert!(cmds.iter().any(|cmd| matches!(cmd, OvpnCommand::EnvFilter(0))));
1168/// assert!(cmds.iter().any(|cmd| matches!(cmd, OvpnCommand::HoldRelease)));
1169/// ```
1170pub fn server_connection_sequence(bytecount_interval: u32, env_filter: u32) -> Vec<OvpnCommand> {
1171    let mut cmds = vec![
1172        OvpnCommand::Log(StreamMode::OnAll),
1173        OvpnCommand::StateStream(StreamMode::OnAll),
1174        OvpnCommand::Pid,
1175        OvpnCommand::EnvFilter(env_filter),
1176    ];
1177    if bytecount_interval > 0 {
1178        cmds.push(OvpnCommand::ByteCount(bytecount_interval));
1179    }
1180    cmds.push(OvpnCommand::HoldRelease);
1181    cmds
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    #[test]
1189    fn into_static_str_labels() {
1190        let label: &str = (&OvpnCommand::State).into();
1191        assert_eq!(label, "state");
1192
1193        let label: &str = (&OvpnCommand::ForgetPasswords).into();
1194        assert_eq!(label, "forget-passwords");
1195
1196        let label: &str = (&OvpnCommand::ByteCount(5)).into();
1197        assert_eq!(label, "byte-count");
1198    }
1199
1200    // --- connection_sequence ---
1201
1202    #[test]
1203    fn connection_sequence_with_bytecount() {
1204        let cmds = connection_sequence(5);
1205        assert_eq!(
1206            cmds,
1207            vec![
1208                OvpnCommand::Log(StreamMode::OnAll),
1209                OvpnCommand::StateStream(StreamMode::OnAll),
1210                OvpnCommand::Pid,
1211                OvpnCommand::ByteCount(5),
1212                OvpnCommand::HoldRelease,
1213            ]
1214        );
1215    }
1216
1217    #[test]
1218    fn connection_sequence_without_bytecount() {
1219        let cmds = connection_sequence(0);
1220        assert_eq!(
1221            cmds,
1222            vec![
1223                OvpnCommand::Log(StreamMode::OnAll),
1224                OvpnCommand::StateStream(StreamMode::OnAll),
1225                OvpnCommand::Pid,
1226                OvpnCommand::HoldRelease,
1227            ]
1228        );
1229    }
1230
1231    // --- FromStr: informational commands ---
1232
1233    #[test]
1234    fn parse_simple_commands() {
1235        assert_eq!("version".parse(), Ok(OvpnCommand::Version));
1236        assert_eq!("pid".parse(), Ok(OvpnCommand::Pid));
1237        assert_eq!("help".parse(), Ok(OvpnCommand::Help));
1238        assert_eq!("net".parse(), Ok(OvpnCommand::Net));
1239        assert_eq!("load-stats".parse(), Ok(OvpnCommand::LoadStats));
1240        assert_eq!("forget-passwords".parse(), Ok(OvpnCommand::ForgetPasswords));
1241        assert_eq!("pkcs11-id-count".parse(), Ok(OvpnCommand::Pkcs11IdCount));
1242        assert_eq!("exit".parse(), Ok(OvpnCommand::Exit));
1243        assert_eq!("quit".parse(), Ok(OvpnCommand::Quit));
1244    }
1245
1246    #[test]
1247    fn parse_status() {
1248        assert_eq!("status".parse(), Ok(OvpnCommand::Status(StatusFormat::V1)));
1249        assert_eq!(
1250            "status 1".parse(),
1251            Ok(OvpnCommand::Status(StatusFormat::V1))
1252        );
1253        assert_eq!(
1254            "status 2".parse(),
1255            Ok(OvpnCommand::Status(StatusFormat::V2))
1256        );
1257        assert_eq!(
1258            "status 3".parse(),
1259            Ok(OvpnCommand::Status(StatusFormat::V3))
1260        );
1261        assert!("status 4".parse::<OvpnCommand>().is_err());
1262    }
1263
1264    // --- FromStr: state / log / echo stream modes ---
1265
1266    #[test]
1267    fn parse_state_bare() {
1268        assert_eq!("state".parse(), Ok(OvpnCommand::State));
1269    }
1270
1271    #[test]
1272    fn parse_state_stream_modes() {
1273        assert_eq!(
1274            "state on".parse(),
1275            Ok(OvpnCommand::StateStream(StreamMode::On))
1276        );
1277        assert_eq!(
1278            "state off".parse(),
1279            Ok(OvpnCommand::StateStream(StreamMode::Off))
1280        );
1281        assert_eq!(
1282            "state all".parse(),
1283            Ok(OvpnCommand::StateStream(StreamMode::All))
1284        );
1285        assert_eq!(
1286            "state on all".parse(),
1287            Ok(OvpnCommand::StateStream(StreamMode::OnAll))
1288        );
1289        assert_eq!(
1290            "state 5".parse(),
1291            Ok(OvpnCommand::StateStream(StreamMode::Recent(5)))
1292        );
1293    }
1294
1295    #[test]
1296    fn parse_log_and_echo() {
1297        assert_eq!("log on".parse(), Ok(OvpnCommand::Log(StreamMode::On)));
1298        assert_eq!(
1299            "log on all".parse(),
1300            Ok(OvpnCommand::Log(StreamMode::OnAll))
1301        );
1302        assert_eq!("echo off".parse(), Ok(OvpnCommand::Echo(StreamMode::Off)));
1303        assert_eq!(
1304            "echo 10".parse(),
1305            Ok(OvpnCommand::Echo(StreamMode::Recent(10)))
1306        );
1307    }
1308
1309    // --- FromStr: verb / mute / bytecount ---
1310
1311    #[test]
1312    fn parse_verb() {
1313        assert_eq!("verb".parse(), Ok(OvpnCommand::Verb(None)));
1314        assert_eq!("verb 4".parse(), Ok(OvpnCommand::Verb(Some(4))));
1315        assert!("verb abc".parse::<OvpnCommand>().is_err());
1316    }
1317
1318    #[test]
1319    fn parse_mute() {
1320        assert_eq!("mute".parse(), Ok(OvpnCommand::Mute(None)));
1321        assert_eq!("mute 40".parse(), Ok(OvpnCommand::Mute(Some(40))));
1322        assert!("mute abc".parse::<OvpnCommand>().is_err());
1323    }
1324
1325    #[test]
1326    fn parse_bytecount() {
1327        assert_eq!("bytecount 5".parse(), Ok(OvpnCommand::ByteCount(5)));
1328        assert_eq!("bytecount 0".parse(), Ok(OvpnCommand::ByteCount(0)));
1329        assert!("bytecount".parse::<OvpnCommand>().is_err());
1330    }
1331
1332    // --- FromStr: signal ---
1333
1334    #[test]
1335    fn parse_signal() {
1336        assert_eq!(
1337            "signal SIGHUP".parse(),
1338            Ok(OvpnCommand::Signal(Signal::SigHup))
1339        );
1340        assert_eq!(
1341            "signal SIGTERM".parse(),
1342            Ok(OvpnCommand::Signal(Signal::SigTerm))
1343        );
1344        assert_eq!(
1345            "signal SIGUSR1".parse(),
1346            Ok(OvpnCommand::Signal(Signal::SigUsr1))
1347        );
1348        assert_eq!(
1349            "signal SIGUSR2".parse(),
1350            Ok(OvpnCommand::Signal(Signal::SigUsr2))
1351        );
1352        assert!("signal SIGKILL".parse::<OvpnCommand>().is_err());
1353    }
1354
1355    // --- FromStr: kill ---
1356
1357    #[test]
1358    fn parse_kill_common_name() {
1359        assert_eq!(
1360            "kill TestClient".parse(),
1361            Ok(OvpnCommand::Kill(KillTarget::CommonName(
1362                "TestClient".to_string()
1363            )))
1364        );
1365    }
1366
1367    #[test]
1368    fn parse_kill_address() {
1369        assert_eq!(
1370            "kill tcp:1.2.3.4:4000".parse(),
1371            Ok(OvpnCommand::Kill(KillTarget::Address {
1372                protocol: TransportProtocol::Tcp,
1373                ip: "1.2.3.4".to_string(),
1374                port: 4000,
1375            }))
1376        );
1377    }
1378
1379    #[test]
1380    fn parse_kill_empty_is_err() {
1381        assert!("kill".parse::<OvpnCommand>().is_err());
1382    }
1383
1384    // --- FromStr: hold ---
1385
1386    #[test]
1387    fn parse_hold() {
1388        assert_eq!("hold".parse(), Ok(OvpnCommand::HoldQuery));
1389        assert_eq!("hold on".parse(), Ok(OvpnCommand::HoldOn));
1390        assert_eq!("hold off".parse(), Ok(OvpnCommand::HoldOff));
1391        assert_eq!("hold release".parse(), Ok(OvpnCommand::HoldRelease));
1392        assert!("hold bogus".parse::<OvpnCommand>().is_err());
1393    }
1394
1395    // --- next_token (OpenVPN lexer) ---
1396
1397    #[test]
1398    fn next_token_unquoted() {
1399        let (tok, rest) = next_token("Auth s3cret").unwrap();
1400        assert_eq!(tok, "Auth");
1401        assert_eq!(rest, "s3cret");
1402    }
1403
1404    #[test]
1405    fn next_token_quoted_simple() {
1406        let (tok, rest) = next_token(r#""Private Key" "s3cret""#).unwrap();
1407        assert_eq!(tok, "Private Key");
1408        assert_eq!(rest, r#""s3cret""#);
1409    }
1410
1411    #[test]
1412    fn next_token_quoted_with_escapes() {
1413        // manage.c lexer: \" → ", \\ → \
1414        let (tok, _) = next_token(r#""foo\\\"bar""#).unwrap();
1415        assert_eq!(tok, r#"foo\"bar"#);
1416    }
1417
1418    #[test]
1419    fn next_token_empty() {
1420        assert!(next_token("").is_none());
1421        assert!(next_token("   ").is_none());
1422    }
1423
1424    #[test]
1425    fn next_token_last_token_no_trailing() {
1426        let (tok, rest) = next_token("onlyone").unwrap();
1427        assert_eq!(tok, "onlyone");
1428        assert_eq!(rest, "");
1429    }
1430
1431    #[test]
1432    fn next_token_unclosed_quote_returns_none() {
1433        assert!(next_token(r#""unclosed string"#).is_none());
1434        assert!(next_token(r#""trailing backslash\"#).is_none());
1435    }
1436
1437    // --- FromStr: authentication ---
1438
1439    #[test]
1440    fn parse_username() {
1441        let cmd: OvpnCommand = "username Auth alice".parse().unwrap();
1442        assert_eq!(
1443            cmd,
1444            OvpnCommand::Username {
1445                auth_type: AuthType::Auth,
1446                value: "alice".into(),
1447            }
1448        );
1449    }
1450
1451    #[test]
1452    fn parse_password() {
1453        let cmd: OvpnCommand = "password Auth s3cret".parse().unwrap();
1454        assert_eq!(
1455            cmd,
1456            OvpnCommand::Password {
1457                auth_type: AuthType::Auth,
1458                value: "s3cret".into(),
1459            }
1460        );
1461    }
1462
1463    #[test]
1464    fn parse_username_missing_value_is_err() {
1465        assert!("username".parse::<OvpnCommand>().is_err());
1466        assert!("username Auth".parse::<OvpnCommand>().is_err());
1467    }
1468
1469    #[test]
1470    fn parse_auth_retry() {
1471        assert_eq!(
1472            "auth-retry none".parse(),
1473            Ok(OvpnCommand::AuthRetry(AuthRetryMode::None))
1474        );
1475        assert_eq!(
1476            "auth-retry interact".parse(),
1477            Ok(OvpnCommand::AuthRetry(AuthRetryMode::Interact))
1478        );
1479        assert_eq!(
1480            "auth-retry nointeract".parse(),
1481            Ok(OvpnCommand::AuthRetry(AuthRetryMode::NoInteract))
1482        );
1483        assert!("auth-retry bogus".parse::<OvpnCommand>().is_err());
1484    }
1485
1486    // --- FromStr: interactive prompts ---
1487
1488    #[test]
1489    fn parse_needok() {
1490        assert_eq!(
1491            "needok token-insertion ok".parse(),
1492            Ok(OvpnCommand::NeedOk {
1493                name: "token-insertion".to_string(),
1494                response: NeedOkResponse::Ok,
1495            })
1496        );
1497        assert_eq!(
1498            "needok token-insertion cancel".parse(),
1499            Ok(OvpnCommand::NeedOk {
1500                name: "token-insertion".to_string(),
1501                response: NeedOkResponse::Cancel,
1502            })
1503        );
1504        assert!("needok".parse::<OvpnCommand>().is_err());
1505        assert!("needok name bogus".parse::<OvpnCommand>().is_err());
1506    }
1507
1508    #[test]
1509    fn parse_needstr() {
1510        assert_eq!(
1511            "needstr prompt-name John".parse(),
1512            Ok(OvpnCommand::NeedStr {
1513                name: "prompt-name".to_string(),
1514                value: "John".to_string(),
1515            })
1516        );
1517        assert!("needstr".parse::<OvpnCommand>().is_err());
1518    }
1519
1520    // --- FromStr: PKCS#11 ---
1521
1522    #[test]
1523    fn parse_pkcs11_id_get() {
1524        assert_eq!("pkcs11-id-get 1".parse(), Ok(OvpnCommand::Pkcs11IdGet(1)));
1525        assert!("pkcs11-id-get abc".parse::<OvpnCommand>().is_err());
1526    }
1527
1528    // --- FromStr: client management ---
1529
1530    #[test]
1531    fn parse_client_auth() {
1532        assert_eq!(
1533            "client-auth 42 7".parse(),
1534            Ok(OvpnCommand::ClientAuth {
1535                cid: 42,
1536                kid: 7,
1537                config_lines: vec![],
1538            })
1539        );
1540    }
1541
1542    #[test]
1543    fn parse_client_auth_with_config() {
1544        let cmd: OvpnCommand = "client-auth 1 2 push route 10.0.0.0,ifconfig-push 10.0.1.1"
1545            .parse()
1546            .unwrap();
1547        assert_eq!(
1548            cmd,
1549            OvpnCommand::ClientAuth {
1550                cid: 1,
1551                kid: 2,
1552                config_lines: vec![
1553                    "push route 10.0.0.0".to_string(),
1554                    "ifconfig-push 10.0.1.1".to_string(),
1555                ],
1556            }
1557        );
1558    }
1559
1560    #[test]
1561    fn parse_client_auth_nt() {
1562        assert_eq!(
1563            "client-auth-nt 5 3".parse(),
1564            Ok(OvpnCommand::ClientAuthNt { cid: 5, kid: 3 })
1565        );
1566        assert!("client-auth-nt abc 3".parse::<OvpnCommand>().is_err());
1567    }
1568
1569    #[test]
1570    fn parse_client_deny() {
1571        assert_eq!(
1572            "client-deny 1 2 rejected".parse(),
1573            Ok(OvpnCommand::ClientDeny(ClientDeny {
1574                cid: 1,
1575                kid: 2,
1576                reason: "rejected".to_string(),
1577                client_reason: None,
1578            }))
1579        );
1580        assert_eq!(
1581            "client-deny 1 2 rejected sorry".parse(),
1582            Ok(OvpnCommand::ClientDeny(ClientDeny {
1583                cid: 1,
1584                kid: 2,
1585                reason: "rejected".to_string(),
1586                client_reason: Some("sorry".to_string()),
1587            }))
1588        );
1589    }
1590
1591    #[test]
1592    fn parse_client_kill() {
1593        assert_eq!(
1594            "client-kill 99".parse(),
1595            Ok(OvpnCommand::ClientKill {
1596                cid: 99,
1597                message: None,
1598            })
1599        );
1600        assert_eq!(
1601            "client-kill 99 HALT".parse(),
1602            Ok(OvpnCommand::ClientKill {
1603                cid: 99,
1604                message: Some("HALT".to_string()),
1605            })
1606        );
1607        assert!("client-kill abc".parse::<OvpnCommand>().is_err());
1608    }
1609
1610    // --- FromStr: remote / proxy ---
1611
1612    #[test]
1613    fn parse_remote() {
1614        assert_eq!(
1615            "remote accept".parse(),
1616            Ok(OvpnCommand::Remote(RemoteAction::Accept))
1617        );
1618        assert_eq!(
1619            "remote SKIP".parse(),
1620            Ok(OvpnCommand::Remote(RemoteAction::Skip))
1621        );
1622        assert_eq!(
1623            "remote MOD example.com 443".parse(),
1624            Ok(OvpnCommand::Remote(RemoteAction::Modify {
1625                host: "example.com".to_string(),
1626                port: 443,
1627            }))
1628        );
1629        assert!("remote".parse::<OvpnCommand>().is_err());
1630    }
1631
1632    #[test]
1633    fn parse_proxy() {
1634        assert_eq!(
1635            "proxy none".parse(),
1636            Ok(OvpnCommand::Proxy(ProxyAction::None))
1637        );
1638        assert_eq!(
1639            "proxy HTTP proxy.local 8080".parse(),
1640            Ok(OvpnCommand::Proxy(ProxyAction::Http {
1641                host: "proxy.local".to_string(),
1642                port: 8080,
1643                non_cleartext_only: false,
1644            }))
1645        );
1646        assert_eq!(
1647            "proxy http proxy.local 8080 nct".parse(),
1648            Ok(OvpnCommand::Proxy(ProxyAction::Http {
1649                host: "proxy.local".to_string(),
1650                port: 8080,
1651                non_cleartext_only: true,
1652            }))
1653        );
1654        assert_eq!(
1655            "proxy socks socks.local 1080".parse(),
1656            Ok(OvpnCommand::Proxy(ProxyAction::Socks {
1657                host: "socks.local".to_string(),
1658                port: 1080,
1659            }))
1660        );
1661        assert!("proxy".parse::<OvpnCommand>().is_err());
1662    }
1663
1664    // --- FromStr: raw / raw-ml / fallback ---
1665
1666    #[test]
1667    fn parse_raw_ml() {
1668        assert_eq!(
1669            "raw-ml some-cmd".parse(),
1670            Ok(OvpnCommand::RawMultiLine("some-cmd".to_string()))
1671        );
1672        assert!("raw-ml".parse::<OvpnCommand>().is_err());
1673    }
1674
1675    #[test]
1676    fn parse_unrecognized_falls_through_to_raw() {
1677        assert_eq!(
1678            "unknown-cmd foo bar".parse(),
1679            Ok(OvpnCommand::Raw("unknown-cmd foo bar".to_string()))
1680        );
1681    }
1682
1683    #[test]
1684    fn parse_trims_whitespace() {
1685        assert_eq!("  version  ".parse(), Ok(OvpnCommand::Version));
1686        assert_eq!(
1687            "  state  on  ".parse(),
1688            Ok(OvpnCommand::StateStream(StreamMode::On))
1689        );
1690    }
1691
1692    // --- FromStr: error paths ---
1693
1694    #[test]
1695    fn parse_state_invalid_stream_mode() {
1696        assert!("state bogus".parse::<OvpnCommand>().is_err());
1697    }
1698
1699    #[test]
1700    fn parse_log_invalid_stream_mode() {
1701        assert!("log bogus".parse::<OvpnCommand>().is_err());
1702    }
1703
1704    #[test]
1705    fn parse_echo_invalid_stream_mode() {
1706        assert!("echo bogus".parse::<OvpnCommand>().is_err());
1707    }
1708
1709    #[test]
1710    fn parse_kill_unknown_protocol_falls_back() {
1711        let cmd: OvpnCommand = "kill sctp:1.2.3.4:4000".parse().unwrap();
1712        assert_eq!(
1713            cmd,
1714            OvpnCommand::Kill(KillTarget::Address {
1715                protocol: TransportProtocol::Unknown("sctp".to_string()),
1716                ip: "1.2.3.4".to_string(),
1717                port: 4000,
1718            })
1719        );
1720    }
1721
1722    #[test]
1723    fn parse_username_unknown_auth_type_falls_back() {
1724        let cmd: OvpnCommand = "username MyPlugin alice".parse().unwrap();
1725        assert_eq!(
1726            cmd,
1727            OvpnCommand::Username {
1728                auth_type: AuthType::Unknown("MyPlugin".to_string()),
1729                value: "alice".into(),
1730            }
1731        );
1732    }
1733
1734    #[test]
1735    fn parse_password_unknown_auth_type_falls_back() {
1736        let cmd: OvpnCommand = "password MyPlugin s3cret".parse().unwrap();
1737        assert_eq!(
1738            cmd,
1739            OvpnCommand::Password {
1740                auth_type: AuthType::Unknown("MyPlugin".to_string()),
1741                value: "s3cret".into(),
1742            }
1743        );
1744    }
1745
1746    #[test]
1747    fn parse_password_missing_value_is_err() {
1748        assert!("password".parse::<OvpnCommand>().is_err());
1749        assert!("password Auth".parse::<OvpnCommand>().is_err());
1750    }
1751
1752    // ---- Spec-compliance: quoted auth types with spaces ----
1753    // Wire format per management-notes.txt ("Command Parsing" section):
1754    //   password "Private Key" "foo\"bar"
1755    //   username "Auth" myuser
1756    // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
1757    //
1758    // Auth types are quoted on the wire and may contain spaces.
1759    // The server validates with streq() in manage.c:
1760    // https://github.com/OpenVPN/openvpn/blob/master/src/openvpn/manage.c
1761
1762    #[test]
1763    fn parse_password_quoted_spaced_auth_type() {
1764        let cmd: OvpnCommand = r#"password "Private Key" "s3cret""#.parse().unwrap();
1765        assert_eq!(
1766            cmd,
1767            OvpnCommand::Password {
1768                auth_type: AuthType::PrivateKey,
1769                value: "s3cret".into(),
1770            }
1771        );
1772    }
1773
1774    #[test]
1775    fn parse_password_quoted_http_proxy() {
1776        let cmd: OvpnCommand = r#"password "HTTP Proxy" "proxypass""#.parse().unwrap();
1777        assert_eq!(
1778            cmd,
1779            OvpnCommand::Password {
1780                auth_type: AuthType::HttpProxy,
1781                value: "proxypass".into(),
1782            }
1783        );
1784    }
1785
1786    #[test]
1787    fn parse_password_quoted_socks_proxy() {
1788        let cmd: OvpnCommand = r#"password "SOCKS Proxy" "sockspass""#.parse().unwrap();
1789        assert_eq!(
1790            cmd,
1791            OvpnCommand::Password {
1792                auth_type: AuthType::SocksProxy,
1793                value: "sockspass".into(),
1794            }
1795        );
1796    }
1797
1798    #[test]
1799    fn parse_username_quoted_spaced_auth_type() {
1800        let cmd: OvpnCommand = r#"username "HTTP Proxy" "proxyuser""#.parse().unwrap();
1801        assert_eq!(
1802            cmd,
1803            OvpnCommand::Username {
1804                auth_type: AuthType::HttpProxy,
1805                value: "proxyuser".into(),
1806            }
1807        );
1808    }
1809
1810    #[test]
1811    fn parse_password_roundtrip_spaced_auth_types() {
1812        // The encoder produces quoted wire format; the parser must
1813        // be able to consume its own output.
1814        use crate::OvpnCodec;
1815        use bytes::BytesMut;
1816        use tokio_util::codec::Encoder;
1817
1818        for auth_type in [
1819            AuthType::PrivateKey,
1820            AuthType::HttpProxy,
1821            AuthType::SocksProxy,
1822        ] {
1823            let original = OvpnCommand::Password {
1824                auth_type: auth_type.clone(),
1825                value: "test".into(),
1826            };
1827            let mut codec = OvpnCodec::new();
1828            let mut buf = BytesMut::new();
1829            codec.encode(original.clone(), &mut buf).unwrap();
1830            let wire = String::from_utf8(buf.to_vec()).unwrap();
1831            let parsed: OvpnCommand = wire.trim().parse().unwrap();
1832            assert_eq!(parsed, original);
1833        }
1834    }
1835
1836    #[test]
1837    fn parse_client_auth_non_numeric_cid() {
1838        assert!("client-auth abc 1".parse::<OvpnCommand>().is_err());
1839    }
1840
1841    #[test]
1842    fn parse_client_auth_non_numeric_kid() {
1843        assert!("client-auth 1 abc".parse::<OvpnCommand>().is_err());
1844    }
1845
1846    #[test]
1847    fn parse_client_auth_nt_non_numeric_kid() {
1848        assert!("client-auth-nt 1 abc".parse::<OvpnCommand>().is_err());
1849    }
1850
1851    #[test]
1852    fn parse_client_deny_missing_args() {
1853        assert!("client-deny".parse::<OvpnCommand>().is_err());
1854        assert!("client-deny 1".parse::<OvpnCommand>().is_err());
1855        assert!("client-deny 1 2".parse::<OvpnCommand>().is_err());
1856    }
1857
1858    #[test]
1859    fn parse_client_deny_non_numeric_ids() {
1860        assert!("client-deny abc 1 reason".parse::<OvpnCommand>().is_err());
1861        assert!("client-deny 1 abc reason".parse::<OvpnCommand>().is_err());
1862    }
1863
1864    #[test]
1865    fn parse_remote_non_numeric_port() {
1866        assert!("remote mod host abc".parse::<OvpnCommand>().is_err());
1867    }
1868
1869    #[test]
1870    fn parse_proxy_non_numeric_port() {
1871        assert!("proxy http host abc".parse::<OvpnCommand>().is_err());
1872        assert!("proxy http host abc nct".parse::<OvpnCommand>().is_err());
1873        assert!("proxy socks host abc".parse::<OvpnCommand>().is_err());
1874    }
1875
1876    #[test]
1877    fn parse_pkcs11_id_get_missing_arg() {
1878        assert!("pkcs11-id-get".parse::<OvpnCommand>().is_err());
1879    }
1880
1881    #[test]
1882    fn parse_bytecount_non_numeric() {
1883        assert!("bytecount abc".parse::<OvpnCommand>().is_err());
1884    }
1885
1886    #[test]
1887    fn parse_needstr_missing_value() {
1888        assert!("needstr".parse::<OvpnCommand>().is_err());
1889    }
1890
1891    // --- FromStr: new commands ---
1892
1893    #[test]
1894    fn parse_env_filter() {
1895        assert_eq!("env-filter 2".parse(), Ok(OvpnCommand::EnvFilter(2)));
1896        assert_eq!("env-filter 0".parse(), Ok(OvpnCommand::EnvFilter(0)));
1897        assert_eq!("env-filter".parse(), Ok(OvpnCommand::EnvFilter(0)));
1898        assert!("env-filter abc".parse::<OvpnCommand>().is_err());
1899    }
1900
1901    #[test]
1902    fn parse_remote_entry_count() {
1903        assert_eq!(
1904            "remote-entry-count".parse(),
1905            Ok(OvpnCommand::RemoteEntryCount)
1906        );
1907    }
1908
1909    #[test]
1910    fn parse_remote_entry_get() {
1911        assert_eq!(
1912            "remote-entry-get 0".parse(),
1913            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Single(0)))
1914        );
1915        assert_eq!(
1916            "remote-entry-get 0 3".parse(),
1917            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::Range {
1918                from: 0,
1919                end: 3
1920            }))
1921        );
1922        assert_eq!(
1923            "remote-entry-get all".parse(),
1924            Ok(OvpnCommand::RemoteEntryGet(RemoteEntryRange::All))
1925        );
1926        assert!("remote-entry-get".parse::<OvpnCommand>().is_err());
1927        assert!("remote-entry-get abc".parse::<OvpnCommand>().is_err());
1928        assert!("remote-entry-get 0 abc".parse::<OvpnCommand>().is_err());
1929    }
1930
1931    #[test]
1932    fn parse_push_update_broad() {
1933        // Wire format: push-update-broad "route 10.0.0.0"
1934        // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
1935        let cmd: OvpnCommand = r#"push-update-broad "route 10.0.0.0""#.parse().unwrap();
1936        assert_eq!(
1937            cmd,
1938            OvpnCommand::PushUpdateBroad {
1939                options: "route 10.0.0.0".to_string()
1940            }
1941        );
1942        assert!("push-update-broad".parse::<OvpnCommand>().is_err());
1943    }
1944
1945    // --- expected_response ---
1946
1947    #[test]
1948    fn exit_quit_expect_no_response() {
1949        assert_eq!(
1950            OvpnCommand::Exit.expected_response(),
1951            ResponseKind::NoResponse,
1952        );
1953        assert_eq!(
1954            OvpnCommand::Quit.expected_response(),
1955            ResponseKind::NoResponse,
1956        );
1957    }
1958
1959    #[test]
1960    fn parse_push_update_cid() {
1961        // Wire format: push-update-cid 42 "route 10.0.0.0"
1962        // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
1963        let cmd: OvpnCommand = r#"push-update-cid 42 "route 10.0.0.0""#.parse().unwrap();
1964        assert_eq!(
1965            cmd,
1966            OvpnCommand::PushUpdateCid {
1967                cid: 42,
1968                options: "route 10.0.0.0".to_string()
1969            }
1970        );
1971        assert!("push-update-cid".parse::<OvpnCommand>().is_err());
1972        assert!("push-update-cid abc opts".parse::<OvpnCommand>().is_err());
1973    }
1974
1975    // --- FromStr: client-pending-auth ---
1976
1977    #[test]
1978    fn parse_client_pending_auth() {
1979        // Wire format: client-pending-auth <cid> <kid> <extra> <timeout>
1980        // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
1981        let cmd: OvpnCommand = "client-pending-auth 42 1 WEB_AUTH::https://example.com 120"
1982            .parse()
1983            .unwrap();
1984        assert_eq!(
1985            cmd,
1986            OvpnCommand::ClientPendingAuth {
1987                cid: 42,
1988                kid: 1,
1989                extra: "WEB_AUTH::https://example.com".to_string(),
1990                timeout: 120,
1991            }
1992        );
1993    }
1994
1995    #[test]
1996    fn parse_client_pending_auth_missing_args() {
1997        assert!("client-pending-auth".parse::<OvpnCommand>().is_err());
1998        assert!("client-pending-auth 1".parse::<OvpnCommand>().is_err());
1999        assert!("client-pending-auth 1 2".parse::<OvpnCommand>().is_err());
2000        assert!(
2001            "client-pending-auth 1 2 extra"
2002                .parse::<OvpnCommand>()
2003                .is_err()
2004        );
2005    }
2006
2007    #[test]
2008    fn parse_client_pending_auth_non_numeric() {
2009        assert!(
2010            "client-pending-auth abc 1 extra 120"
2011                .parse::<OvpnCommand>()
2012                .is_err()
2013        );
2014        assert!(
2015            "client-pending-auth 1 abc extra 120"
2016                .parse::<OvpnCommand>()
2017                .is_err()
2018        );
2019        assert!(
2020            "client-pending-auth 1 2 extra abc"
2021                .parse::<OvpnCommand>()
2022                .is_err()
2023        );
2024    }
2025
2026    // --- FromStr: cr-response ---
2027
2028    #[test]
2029    fn parse_cr_response() {
2030        // Wire format: cr-response <base64-response>
2031        // https://github.com/OpenVPN/openvpn/blob/master/doc/management-notes.txt
2032        let cmd: OvpnCommand = "cr-response dGVzdA==".parse().unwrap();
2033        assert_eq!(
2034            cmd,
2035            OvpnCommand::CrResponse {
2036                response: Redacted::new("dGVzdA=="),
2037            }
2038        );
2039    }
2040
2041    #[test]
2042    fn parse_cr_response_missing_arg() {
2043        assert!("cr-response".parse::<OvpnCommand>().is_err());
2044    }
2045}