Skip to main content

openvpn_mgmt_codec/
parsed_response.rs

1//! Typed parsers for `SUCCESS:` payloads and multi-line responses.
2//!
3//! The management protocol's `SUCCESS:` line carries structured data as a
4//! plain string (e.g. `SUCCESS: pid=12345`). These utilities parse common
5//! payloads into typed values, saving every consumer from re-implementing
6//! the same string splitting.
7//!
8//! # Examples
9//!
10//! ```
11//! use openvpn_mgmt_codec::parsed_response::{parse_pid, parse_load_stats, LoadStats};
12//!
13//! assert_eq!(parse_pid("pid=12345"), Ok(12345));
14//!
15//! let stats = parse_load_stats("nclients=3,bytesin=100000,bytesout=50000").unwrap();
16//! assert_eq!(stats.nclients, 3);
17//! ```
18
19use crate::version_info::VersionInfo;
20
21/// Aggregated server statistics from `load-stats`.
22///
23/// Wire format: `SUCCESS: nclients=N,bytesin=N,bytesout=N`
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct LoadStats {
26    /// Number of currently connected clients.
27    pub nclients: u64,
28    /// Total bytes received by the server.
29    pub bytesin: u64,
30    /// Total bytes sent by the server.
31    pub bytesout: u64,
32}
33
34/// Error returned when a `SUCCESS:` payload cannot be parsed.
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum ParseResponseError {
37    /// Expected `pid=N` but the prefix was missing.
38    #[error("missing 'pid=' prefix in: {0:?}")]
39    MissingPidPrefix(String),
40
41    /// Expected `hold=0` or `hold=1` but the prefix was missing.
42    #[error("missing 'hold=' prefix in: {0:?}")]
43    MissingHoldPrefix(String),
44
45    /// `hold=` value was not `0` or `1`.
46    #[error("invalid hold value: {0:?}")]
47    InvalidHoldValue(String),
48
49    /// A numeric field could not be parsed.
50    #[error("invalid integer for field {field:?}: {value:?}")]
51    InvalidInteger {
52        /// The field name that failed to parse.
53        field: &'static str,
54        /// The raw value that could not be parsed.
55        value: String,
56    },
57
58    /// A required field was missing from the `load-stats` payload.
59    #[error("missing field {0:?} in load-stats payload")]
60    MissingField(&'static str),
61
62    /// An unrecognized key appeared in the `load-stats` payload.
63    #[error("unexpected field {0:?} in load-stats payload")]
64    UnexpectedField(String),
65}
66
67/// Parse the `SUCCESS:` payload from a `pid` command.
68///
69/// Expects the format `pid=N` and returns the PID as `u32`.
70///
71/// ```
72/// use openvpn_mgmt_codec::parsed_response::parse_pid;
73/// assert_eq!(parse_pid("pid=12345"), Ok(12345));
74/// assert!(parse_pid("garbage").is_err());
75/// ```
76pub fn parse_pid(payload: &str) -> Result<u32, ParseResponseError> {
77    let val = payload
78        .strip_prefix("pid=")
79        .ok_or_else(|| ParseResponseError::MissingPidPrefix(payload.to_string()))?;
80    val.parse().map_err(|_| ParseResponseError::InvalidInteger {
81        field: "pid",
82        value: val.to_string(),
83    })
84}
85
86/// Parse the `SUCCESS:` payload from a `load-stats` command.
87///
88/// Expects the format `nclients=N,bytesin=N,bytesout=N`.
89///
90/// ```
91/// use openvpn_mgmt_codec::parsed_response::parse_load_stats;
92/// let stats = parse_load_stats("nclients=5,bytesin=1000,bytesout=2000").unwrap();
93/// assert_eq!(stats.nclients, 5);
94/// assert_eq!(stats.bytesin, 1000);
95/// assert_eq!(stats.bytesout, 2000);
96/// ```
97pub fn parse_load_stats(payload: &str) -> Result<LoadStats, ParseResponseError> {
98    let mut nclients = None;
99    let mut bytesin = None;
100    let mut bytesout = None;
101
102    for part in payload.split(',') {
103        if let Some((key, val)) = part.split_once('=') {
104            let parsed = |field| {
105                val.parse().map_err(|_| ParseResponseError::InvalidInteger {
106                    field,
107                    value: val.to_string(),
108                })
109            };
110            match key {
111                "nclients" => nclients = Some(parsed("nclients")?),
112                "bytesin" => bytesin = Some(parsed("bytesin")?),
113                "bytesout" => bytesout = Some(parsed("bytesout")?),
114                other => return Err(ParseResponseError::UnexpectedField(other.to_string())),
115            }
116        }
117    }
118
119    Ok(LoadStats {
120        nclients: nclients.ok_or(ParseResponseError::MissingField("nclients"))?,
121        bytesin: bytesin.ok_or(ParseResponseError::MissingField("bytesin"))?,
122        bytesout: bytesout.ok_or(ParseResponseError::MissingField("bytesout"))?,
123    })
124}
125
126/// Parse the `SUCCESS:` payload from a `hold` query.
127///
128/// Expects the format `hold=0` or `hold=1`. Returns `true` when hold is
129/// active.
130///
131/// ```
132/// use openvpn_mgmt_codec::parsed_response::parse_hold;
133/// assert_eq!(parse_hold("hold=1"), Ok(true));
134/// assert_eq!(parse_hold("hold=0"), Ok(false));
135/// ```
136pub fn parse_hold(payload: &str) -> Result<bool, ParseResponseError> {
137    let val = payload
138        .strip_prefix("hold=")
139        .ok_or_else(|| ParseResponseError::MissingHoldPrefix(payload.to_string()))?;
140    match val {
141        "1" => Ok(true),
142        "0" => Ok(false),
143        _ => Err(ParseResponseError::InvalidHoldValue(val.to_string())),
144    }
145}
146
147/// Parse the multi-line response from a `version` command into a
148/// [`VersionInfo`].
149///
150/// This is a convenience wrapper around [`VersionInfo::parse`].
151///
152/// ```
153/// use openvpn_mgmt_codec::parsed_response::parse_version;
154///
155/// let lines = vec![
156///     "OpenVPN Version: OpenVPN 2.6.9 x86_64-pc-linux-gnu".to_string(),
157///     "Management Interface Version: 5".to_string(),
158/// ];
159/// let info = parse_version(&lines);
160/// assert_eq!(info.management_version(), Some(5));
161/// ```
162pub fn parse_version(lines: &[String]) -> VersionInfo {
163    VersionInfo::parse(lines)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    // --- parse_pid ---
171
172    #[test]
173    fn pid_normal() {
174        assert_eq!(parse_pid("pid=42"), Ok(42));
175    }
176
177    #[test]
178    fn pid_zero() {
179        assert_eq!(parse_pid("pid=0"), Ok(0));
180    }
181
182    #[test]
183    fn pid_missing_prefix() {
184        assert!(parse_pid("42").is_err());
185    }
186
187    #[test]
188    fn pid_not_a_number() {
189        assert!(parse_pid("pid=abc").is_err());
190    }
191
192    // --- parse_load_stats ---
193
194    #[test]
195    fn load_stats_normal() {
196        let s = parse_load_stats("nclients=10,bytesin=123456,bytesout=789012").unwrap();
197        assert_eq!(s.nclients, 10);
198        assert_eq!(s.bytesin, 123456);
199        assert_eq!(s.bytesout, 789012);
200    }
201
202    #[test]
203    fn load_stats_reordered() {
204        let s = parse_load_stats("bytesout=1,nclients=2,bytesin=3").unwrap();
205        assert_eq!(s.nclients, 2);
206        assert_eq!(s.bytesin, 3);
207        assert_eq!(s.bytesout, 1);
208    }
209
210    #[test]
211    fn load_stats_missing_field() {
212        let err = parse_load_stats("nclients=1,bytesin=2").unwrap_err();
213        assert!(matches!(err, ParseResponseError::MissingField("bytesout")));
214    }
215
216    #[test]
217    fn load_stats_non_numeric_value() {
218        let err = parse_load_stats("nclients=abc,bytesin=2,bytesout=3").unwrap_err();
219        assert!(matches!(
220            err,
221            ParseResponseError::InvalidInteger {
222                field: "nclients",
223                ..
224            }
225        ));
226    }
227
228    #[test]
229    fn load_stats_unexpected_field() {
230        let err = parse_load_stats("nclients=1,bytesin=2,bytesout=3,extra=99").unwrap_err();
231        assert!(matches!(err, ParseResponseError::UnexpectedField(f) if f == "extra"));
232    }
233
234    // --- parse_hold ---
235
236    #[test]
237    fn hold_active() {
238        assert_eq!(parse_hold("hold=1"), Ok(true));
239    }
240
241    #[test]
242    fn hold_inactive() {
243        assert_eq!(parse_hold("hold=0"), Ok(false));
244    }
245
246    #[test]
247    fn hold_missing_prefix() {
248        assert!(parse_hold("garbage").is_err());
249    }
250
251    #[test]
252    fn hold_invalid_value() {
253        assert!(parse_hold("hold=maybe").is_err());
254    }
255
256    // --- parse_version ---
257
258    #[test]
259    fn version_roundtrip() {
260        let lines = vec![
261            "OpenVPN Version: OpenVPN 2.5.0".to_string(),
262            "Management Interface Version: 4".to_string(),
263        ];
264        let info = parse_version(&lines);
265        assert_eq!(info.management_version(), Some(4));
266    }
267}