Skip to main content

netget/server/nut/
wire.rs

1//! RFC 9271/NUT framing shared by the server and client. No application storage.
2use anyhow::{bail, ensure, Context, Result};
3use serde_json::{json, Value};
4use std::time::Duration;
5use tokio::io::{AsyncBufRead, AsyncBufReadExt};
6
7pub const MAX_LINE_BYTES: usize = 8192;
8pub const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
9pub const MAX_ENTRIES: usize = 4096;
10pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
11
12pub fn quote(s: &str) -> Result<String> {
13    ensure!(
14        s.bytes().all(|b| (32..=126).contains(&b)),
15        "NUT strings must contain printable ASCII only"
16    );
17    Ok(format!(
18        "\"{}\"",
19        s.replace('\\', "\\\\").replace('"', "\\\"")
20    ))
21}
22pub fn token(s: &str) -> Result<&str> {
23    ensure!(
24        !s.is_empty()
25            && s.bytes()
26                .all(|b| (33..=126).contains(&b) && b != b'"' && b != b'\\'),
27        "Invalid NUT identifier"
28    );
29    Ok(s)
30}
31/// Parse quoted strings without permitting newline injection or unfinished escapes.
32pub fn split(line: &str) -> Result<Vec<String>> {
33    ensure!(
34        line.bytes().all(|b| (32..=126).contains(&b)),
35        "Invalid NUT character"
36    );
37    let mut out = Vec::new();
38    let mut chars = line.chars().peekable();
39    while chars.peek().is_some() {
40        while chars.peek() == Some(&' ') {
41            chars.next();
42        }
43        if chars.peek().is_none() {
44            break;
45        }
46        let quoted = chars.peek() == Some(&'"');
47        if quoted {
48            chars.next();
49        }
50        let mut field = String::new();
51        let mut closed = !quoted;
52        while let Some(c) = chars.next() {
53            if c == '\\' {
54                let c = chars.next().context("Incomplete escape")?;
55                ensure!(c == '\\' || c == '"', "Invalid escape");
56                field.push(c);
57            } else if c == '"' {
58                ensure!(quoted, "Unexpected quote");
59                closed = true;
60                ensure!(
61                    chars.peek().is_none() || chars.peek() == Some(&' '),
62                    "Text after quoted string"
63                );
64                break;
65            } else if c == ' ' && !quoted {
66                break;
67            } else {
68                field.push(c);
69            }
70        }
71        ensure!(closed, "Unterminated quoted string");
72        out.push(field);
73        ensure!(out.len() <= 16, "Too many NUT fields");
74    }
75    ensure!(!out.is_empty(), "Empty NUT line");
76    Ok(out)
77}
78/// A whole-line deadline and a hard allocation bound; EOF never completes a partial line.
79pub async fn read_line<R: AsyncBufRead + Unpin>(
80    r: &mut R,
81    timeout: Duration,
82) -> Result<Option<String>> {
83    tokio::time::timeout(timeout, async {
84        let mut line = Vec::new();
85        loop {
86            let bytes = r.fill_buf().await?;
87            if bytes.is_empty() {
88                ensure!(line.is_empty(), "Truncated NUT line");
89                return Ok(None);
90            }
91            let count = bytes
92                .iter()
93                .position(|b| *b == b'\n')
94                .map(|n| n + 1)
95                .unwrap_or(bytes.len());
96            ensure!(
97                line.len() + count <= MAX_LINE_BYTES,
98                "NUT line exceeds limit"
99            );
100            line.extend_from_slice(&bytes[..count]);
101            r.consume(count);
102            if line.last() == Some(&b'\n') {
103                line.pop();
104                // Tolerate CRLF peers; outbound framing is LF as RFC 9271 specifies.
105                if line.last() == Some(&b'\r') {
106                    line.pop();
107                }
108                return Ok(Some(String::from_utf8(line)?));
109            }
110        }
111    })
112    .await
113    .context("NUT line deadline exceeded")?
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
117pub struct Request {
118    pub operation: String,
119    pub ups: Option<String>,
120    pub name: Option<String>,
121    pub value: Option<String>,
122}
123impl Request {
124    pub fn from_action(v: &Value) -> Result<Self> {
125        let r: Self = serde_json::from_value(v.clone())?;
126        r.encode()?;
127        Ok(r)
128    }
129    pub fn encode(&self) -> Result<String> {
130        let ups = || token(self.ups.as_deref().context("Missing ups")?).map(str::to_owned);
131        let name = || token(self.name.as_deref().context("Missing name")?).map(str::to_owned);
132        let value = || quote(self.value.as_deref().context("Missing value")?);
133        let line = match self.operation.as_str() {
134            "list_ups" => "LIST UPS".into(),
135            "list_var" | "list_rw" | "list_cmd" => format!(
136                "LIST {} {}",
137                &self.operation[5..].to_ascii_uppercase(),
138                ups()?
139            ),
140            "list_enum" => format!("LIST ENUM {} {}", ups()?, name()?),
141            "get_var" | "get_desc" | "get_cmddesc" | "get_type" => format!(
142                "GET {} {} {}",
143                &self.operation[4..].to_ascii_uppercase(),
144                ups()?,
145                name()?
146            ),
147            "get_upsdesc" => format!("GET UPSDESC {}", ups()?),
148            "set_var" => format!("SET VAR {} {} {}", ups()?, name()?, value()?),
149            "instcmd" => format!("INSTCMD {} {}", ups()?, name()?),
150            "username" | "password" => {
151                format!("{} {}", self.operation.to_ascii_uppercase(), value()?)
152            }
153            "logout" => "LOGOUT".into(),
154            _ => bail!("Unsupported NUT operation: {}", self.operation),
155        };
156        ensure!(line.len() + 1 <= MAX_LINE_BYTES, "NUT request too long");
157        Ok(format!("{line}\n"))
158    }
159    pub fn parse(line: &str) -> Result<Self> {
160        let p = split(line)?;
161        let a: Vec<&str> = p.iter().map(String::as_str).collect();
162        let (op, ups, name, value) = match a.as_slice() {
163            ["LIST", "UPS"] => ("list_ups".into(), None, None, None),
164            ["LIST", kind @ ("VAR" | "RW" | "CMD"), ups] => (
165                format!("list_{}", kind.to_ascii_lowercase()),
166                Some(*ups),
167                None,
168                None,
169            ),
170            ["LIST", "ENUM", ups, name] => ("list_enum".into(), Some(*ups), Some(*name), None),
171            ["GET", kind @ ("VAR" | "DESC" | "CMDDESC" | "TYPE"), ups, name] => (
172                format!("get_{}", kind.to_ascii_lowercase()),
173                Some(*ups),
174                Some(*name),
175                None,
176            ),
177            ["GET", "UPSDESC", ups] => ("get_upsdesc".into(), Some(*ups), None, None),
178            ["SET", "VAR", ups, name, value] => {
179                ("set_var".into(), Some(*ups), Some(*name), Some(*value))
180            }
181            ["INSTCMD", ups, name] => ("instcmd".into(), Some(*ups), Some(*name), None),
182            [kind @ ("USERNAME" | "PASSWORD"), value] => {
183                (kind.to_ascii_lowercase(), None, None, Some(*value))
184            }
185            ["LOGOUT"] | ["DETACH"] => ("logout".into(), None, None, None),
186            _ => bail!("Unsupported command or invalid arguments"),
187        };
188        let r = Self {
189            operation: op,
190            ups: ups.map(str::to_owned),
191            name: name.map(str::to_owned),
192            value: value.map(str::to_owned),
193        };
194        r.encode()?;
195        Ok(r)
196    }
197    pub fn is_write(&self) -> bool {
198        matches!(self.operation.as_str(), "set_var" | "instcmd")
199    }
200    pub fn list_suffix(&self) -> Result<String> {
201        Ok(self
202            .encode()?
203            .trim_end()
204            .strip_prefix("LIST ")
205            .context("Not a list")?
206            .to_owned())
207    }
208    pub fn public_data(&self) -> Value {
209        let mut v = json!(self);
210        if self.operation == "password" {
211            v["value"] = json!("[redacted]");
212        }
213        v
214    }
215}
216
217pub fn error_reply(error: &str) -> Result<String> {
218    ensure!(
219        matches!(
220            error,
221            "ACCESS-DENIED"
222                | "UNKNOWN-UPS"
223                | "VAR-NOT-SUPPORTED"
224                | "CMD-NOT-SUPPORTED"
225                | "INVALID-ARGUMENT"
226                | "DATA-STALE"
227                | "DRIVER-NOT-CONNECTED"
228                | "READONLY"
229                | "SET-FAILED"
230                | "INSTCMD-FAILED"
231                | "UNKNOWN-COMMAND"
232                | "FEATURE-NOT-SUPPORTED"
233                | "ALREADY-SET"
234                | "USERNAME-REQUIRED"
235                | "PASSWORD-REQUIRED"
236        ),
237        "Unsupported NUT error token"
238    );
239    Ok(format!("ERR {error}\n"))
240}
241fn field<'a>(v: &'a Value, key: &str) -> Result<&'a str> {
242    v[key]
243        .as_str()
244        .with_context(|| format!("Missing string {key}"))
245}
246/// Render reply identities from the actual request, never from model-supplied framing.
247pub fn render(r: &Request, v: &Value) -> Result<String> {
248    if let Some(error) = v.get("error") {
249        return error_reply(error.as_str().context("error must be a string")?);
250    }
251    let ups = r.ups.as_deref().unwrap_or("");
252    let name = r.name.as_deref().unwrap_or("");
253    let op = r.operation.as_str();
254    let mut lines = Vec::new();
255    if op.starts_with("list_") {
256        let entries = v["entries"].as_array().context("Missing entries array")?;
257        ensure!(entries.len() <= MAX_ENTRIES, "NUT list exceeds entry limit");
258        let suffix = r.list_suffix()?;
259        lines.push(format!("BEGIN LIST {suffix}"));
260        for e in entries {
261            lines.push(match op {
262                "list_ups" => format!(
263                    "UPS {} {}",
264                    token(field(e, "name")?)?,
265                    quote(field(e, "description")?)?
266                ),
267                "list_var" | "list_rw" => format!(
268                    "{} {} {} {}",
269                    &op[5..].to_ascii_uppercase(),
270                    ups,
271                    token(field(e, "name")?)?,
272                    quote(field(e, "value")?)?
273                ),
274                "list_cmd" => format!("CMD {} {}", ups, token(field(e, "name")?)?),
275                "list_enum" => format!("ENUM {} {} {}", ups, name, quote(field(e, "value")?)?),
276                _ => bail!("Unsupported list"),
277            });
278        }
279        lines.push(format!("END LIST {suffix}"));
280    } else {
281        lines.push(match op {
282            "get_var" | "get_desc" | "get_cmddesc" => format!(
283                "{} {} {} {}",
284                &op[4..].to_ascii_uppercase(),
285                ups,
286                name,
287                quote(field(v, "value")?)?
288            ),
289            "get_upsdesc" => format!("UPSDESC {} {}", ups, quote(field(v, "value")?)?),
290            "get_type" => {
291                let types = v["types"].as_array().context("Missing types")?;
292                ensure!(!types.is_empty() && types.len() <= 8, "Invalid types count");
293                let mut out = format!("TYPE {ups} {name}");
294                for t in types {
295                    let t = t.as_str().context("Type must be string")?;
296                    ensure!(
297                        matches!(t, "RW" | "ENUM" | "RANGE" | "NUMBER")
298                            || t.strip_prefix("STRING:")
299                                .and_then(|s| s.parse::<u32>().ok())
300                                .is_some(),
301                        "Invalid variable type"
302                    );
303                    out.push(' ');
304                    out.push_str(t);
305                }
306                out
307            }
308            "set_var" | "instcmd" => {
309                ensure!(v["ok"] == true, "Write needs explicit ok=true");
310                "OK".into()
311            }
312            _ => bail!("Unsupported response operation"),
313        });
314    }
315    ensure!(
316        lines.iter().all(|s| s.len() < MAX_LINE_BYTES),
317        "NUT response line too long"
318    );
319    let out = format!("{}\n", lines.join("\n"));
320    ensure!(out.len() <= MAX_RESPONSE_BYTES, "NUT response too large");
321    Ok(out)
322}
323
324/// Read and validate a complete response, matching every row and terminator to the request.
325pub async fn read_response<R: AsyncBufRead + Unpin>(reader: &mut R, r: &Request) -> Result<Value> {
326    read_response_with_timeout(reader, r, IO_TIMEOUT).await
327}
328
329/// Same parser with an explicit transaction deadline, including all list rows.
330pub async fn read_response_with_timeout<R: AsyncBufRead + Unpin>(
331    reader: &mut R,
332    r: &Request,
333    timeout: Duration,
334) -> Result<Value> {
335    tokio::time::timeout(timeout, async {
336        let first = read_line(reader, IO_TIMEOUT)
337            .await?
338            .context("EOF before NUT reply")?;
339        let fields = split(&first)?;
340        if fields.first().map(String::as_str) == Some("ERR") {
341            ensure!(fields.len() == 2, "Malformed NUT error");
342            return Ok(json!({"error": fields[1]}));
343        }
344        if r.operation.starts_with("list_") {
345            let suffix = r.list_suffix()?;
346            ensure!(
347                first == format!("BEGIN LIST {suffix}"),
348                "Mismatched list header"
349            );
350            let mut entries = Vec::new();
351            let mut bytes = first.len();
352            loop {
353                let line = read_line(reader, IO_TIMEOUT)
354                    .await?
355                    .context("Truncated NUT list")?;
356                bytes += line.len() + 1;
357                ensure!(bytes <= MAX_RESPONSE_BYTES, "NUT response too large");
358                if line == format!("END LIST {suffix}") {
359                    break;
360                }
361                ensure!(entries.len() < MAX_ENTRIES, "Too many NUT entries");
362                let p = split(&line)?;
363                entries.push(parse_row(r, &p)?);
364            }
365            Ok(json!({"entries": entries}))
366        } else if matches!(
367            r.operation.as_str(),
368            "username" | "password" | "set_var" | "instcmd" | "logout"
369        ) {
370            ensure!(
371                first == "OK" || (r.operation == "logout" && first == "OK Goodbye"),
372                "Unexpected NUT acknowledgement"
373            );
374            Ok(json!({"ok": true}))
375        } else {
376            parse_row(r, &fields)
377        }
378    })
379    .await
380    .context("NUT response deadline exceeded")?
381}
382fn parse_row(r: &Request, p: &[String]) -> Result<Value> {
383    let op = r.operation.as_str();
384    let kind = op
385        .split_once('_')
386        .context("Invalid operation")?
387        .1
388        .to_ascii_uppercase();
389    ensure!(p.first() == Some(&kind), "Mismatched NUT response kind");
390    if op == "list_ups" {
391        ensure!(p.len() == 3, "Malformed UPS entry");
392        token(&p[1])?;
393        return Ok(json!({"name": p[1], "description": p[2]}));
394    }
395    ensure!(p.get(1) == r.ups.as_ref(), "Mismatched UPS");
396    if op == "get_upsdesc" {
397        ensure!(p.len() == 3, "Malformed UPSDESC");
398        return Ok(json!({"value": p[2]}));
399    }
400    if op == "list_cmd" {
401        ensure!(p.len() == 3, "Malformed CMD");
402        token(&p[2])?;
403        return Ok(json!({"name": p[2]}));
404    }
405    if !matches!(op, "list_var" | "list_rw") {
406        ensure!(
407            p.get(2) == r.name.as_ref(),
408            "Mismatched variable or command"
409        );
410    }
411    if op == "get_type" {
412        ensure!(p.len() >= 4, "Missing TYPE value");
413        return Ok(json!({"types": &p[3..]}));
414    }
415    ensure!(p.len() == 4, "Malformed NUT value");
416    Ok(if matches!(op, "list_var" | "list_rw") {
417        token(&p[2])?;
418        json!({"name": p[2], "value": p[3]})
419    } else {
420        json!({"value": p[3]})
421    })
422}