1use 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}
31pub 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}
78pub 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 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}
246pub 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
324pub 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
329pub 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}