netget/server/lpd/
wire.rs1use anyhow::{bail, ensure, Context, Result};
7use serde_json::{json, Value};
8use std::time::Duration;
9use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt};
10
11pub const MAX_COMMAND_LINE: usize = 1024;
13pub const MAX_CONTROL_FILE: u64 = 64 * 1024;
15pub const DEFAULT_MAX_JOB_BYTES: u64 = 16 * 1024 * 1024;
17pub const MAX_JOB_BYTES_LIMIT: u64 = 256 * 1024 * 1024;
19pub const MAX_FILES_PER_JOB: usize = 52;
21pub const MAX_PREVIEW: usize = 64 * 1024;
23pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
25pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
27
28pub const CMD_PRINT_WAITING: u8 = 0x01;
30pub const CMD_RECEIVE_JOB: u8 = 0x02;
31pub const CMD_QUEUE_SHORT: u8 = 0x03;
32pub const CMD_QUEUE_LONG: u8 = 0x04;
33pub const CMD_REMOVE_JOBS: u8 = 0x05;
34pub const SUB_ABORT: u8 = 0x01;
36pub const SUB_CONTROL_FILE: u8 = 0x02;
37pub const SUB_DATA_FILE: u8 = 0x03;
38
39pub async fn read_line<R: AsyncBufRead + Unpin>(
41 reader: &mut R,
42 deadline: Duration,
43) -> Result<Option<Vec<u8>>> {
44 tokio::time::timeout(deadline, async {
45 let mut line = Vec::new();
46 loop {
47 let available = reader.fill_buf().await?;
48 if available.is_empty() {
49 if line.is_empty() {
50 return Ok(None);
51 }
52 bail!("LPD peer closed mid-command");
53 }
54 let (chunk, found) = match available.iter().position(|b| *b == b'\n') {
55 Some(i) => (&available[..=i], true),
56 None => (available, false),
57 };
58 ensure!(
59 line.len() + chunk.len() <= MAX_COMMAND_LINE,
60 "LPD command line exceeds {MAX_COMMAND_LINE} bytes"
61 );
62 line.extend_from_slice(chunk);
63 let taken = chunk.len();
64 reader.consume(taken);
65 if found {
66 line.pop();
67 return Ok(Some(line));
68 }
69 }
70 })
71 .await
72 .context("LPD read deadline")?
73}
74
75pub async fn read_file<R: AsyncRead + Unpin>(
77 reader: &mut R,
78 count: u64,
79 deadline: Duration,
80) -> Result<Vec<u8>> {
81 let mut body = vec![0u8; usize::try_from(count)?];
82 tokio::time::timeout(deadline, async {
83 reader.read_exact(&mut body).await?;
84 let mut nul = [0u8; 1];
85 reader.read_exact(&mut nul).await?;
86 ensure!(nul[0] == 0, "LPD file not terminated by a NUL byte");
87 Ok(())
88 })
89 .await
90 .context("LPD file transfer deadline")??;
91 Ok(body)
92}
93
94pub fn valid_token(token: &str) -> bool {
96 !token.is_empty() && token.len() <= 128 && token.bytes().all(|b| b.is_ascii_graphic())
97}
98
99pub fn operands(line: &[u8]) -> Vec<String> {
101 String::from_utf8_lossy(line)
102 .split([' ', '\t'])
103 .filter(|s| !s.is_empty())
104 .map(str::to_string)
105 .collect()
106}
107
108pub fn job_number(name: &str) -> Option<(String, String)> {
110 let rest = name.get(3..)?;
111 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
112 if digits.is_empty() {
113 return None;
114 }
115 Some((digits.clone(), rest[digits.len()..].to_string()))
116}
117
118#[derive(Debug, Clone, Default)]
120pub struct ControlFile {
121 pub host: Option<String>,
122 pub user: Option<String>,
123 pub job_name: Option<String>,
124 pub title: Option<String>,
125 pub class: Option<String>,
126 pub banner_user: Option<String>,
127 pub mail: Option<String>,
128 pub prints: Vec<(char, String, Option<String>)>,
130 pub unlink: Vec<String>,
131}
132
133const FORMATS: &str = "cdfglnoprtvz";
135
136impl ControlFile {
137 pub fn parse(bytes: &[u8]) -> Result<Self> {
138 let text = std::str::from_utf8(bytes).context("LPD control file is not UTF-8")?;
139 let mut control = ControlFile::default();
140 let mut source: Option<String> = None;
141 for line in text.split('\n') {
142 let line = line.strip_suffix('\r').unwrap_or(line);
143 let mut chars = line.chars();
144 let Some(code) = chars.next() else { continue };
145 let value = chars.as_str().to_string();
146 ensure!(
147 !crate::utils::sanitize::has_controls(&value),
148 "LPD control line contains control characters"
149 );
150 match code {
151 'H' => control.host = Some(value),
152 'P' => control.user = Some(value),
153 'J' => control.job_name = Some(value),
154 'T' => control.title = Some(value),
155 'C' => control.class = Some(value),
156 'L' => control.banner_user = Some(value),
157 'M' => control.mail = Some(value),
158 'N' => source = Some(value),
159 'U' => control.unlink.push(value),
160 c if FORMATS.contains(c) => {
161 ensure!(
162 value.starts_with("df"),
163 "LPD print line must name a data file"
164 );
165 if !control.prints.iter().any(|(_, name, _)| *name == value) {
166 ensure!(
167 control.prints.len() < MAX_FILES_PER_JOB,
168 "LPD job names too many data files"
169 );
170 }
171 control.prints.push((c, value, source.take()));
172 }
173 _ => {}
175 }
176 }
177 Ok(control)
178 }
179
180 pub fn data_files(&self) -> Vec<String> {
182 let mut names: Vec<String> = Vec::new();
183 for (_, name, _) in &self.prints {
184 if !names.contains(name) {
185 names.push(name.clone());
186 }
187 }
188 names
189 }
190}
191
192pub fn preview(bytes: &[u8]) -> (Value, bool) {
194 let shown = &bytes[..bytes.len().min(MAX_PREVIEW)];
195 let text = match std::str::from_utf8(shown) {
196 Ok(text) => text,
197 Err(e) if e.error_len().is_none() => {
198 std::str::from_utf8(&shown[..e.valid_up_to()]).unwrap_or("")
199 }
200 Err(_) => return (Value::Null, true),
201 };
202 let binary =
204 crate::utils::sanitize::has_controls(&text.replace(['\n', '\r', '\t', '\x0c'], ""));
205 if binary {
206 (Value::Null, true)
207 } else {
208 (json!(text), false)
209 }
210}
211
212pub fn clean(text: &str, limit: usize) -> String {
214 let mut out = String::new();
215 for c in crate::utils::sanitize::line_field(text).chars() {
216 if out.len() + c.len_utf8() > limit {
217 break;
218 }
219 out.push(c);
220 }
221 out
222}
223
224pub fn render_queue(queue: &str, long: bool, status: Option<&str>, jobs: &[Value]) -> String {
226 let mut out = String::new();
227 out.push_str(&clean(status.unwrap_or(&format!("{queue} is ready")), 200));
228 out.push('\n');
229 if jobs.is_empty() {
230 out.push_str("no entries\n");
231 return out;
232 }
233 if !long {
234 out.push_str("Rank Owner Job Files Total Size\n");
235 }
236 for (i, job) in jobs.iter().enumerate() {
237 let rank = job["rank"].as_str().map(str::to_string).unwrap_or_else(|| {
238 if i == 0 {
239 "active".into()
240 } else {
241 format!("{}", i + 1)
242 }
243 });
244 let owner = clean(job["owner"].as_str().unwrap_or("unknown"), 32);
245 let id = job["job_id"].as_u64().unwrap_or(i as u64 + 1);
246 let files = clean(job["files"].as_str().unwrap_or("(stdin)"), 120);
247 let size = job["size"].as_u64().unwrap_or(0);
248 if long {
249 out.push_str(&format!(
250 "\n{owner}: {rank:<28}[job {id:03}]\n {files:<32}{size} bytes\n"
251 ));
252 } else {
253 out.push_str(&format!(
254 "{rank:<7}{owner:<11}{id:<5}{files:<38}{size} bytes\n"
255 ));
256 }
257 }
258 out
259}