Skip to main content

netget/server/lpd/
wire.rs

1//! LPD (RFC 1179) framing shared by the server and the client: one command per connection,
2//! LF-terminated command lines, receive-job subcommands carrying a byte count, the file's
3//! bytes and a trailing NUL, and the control-file format.
4//!
5//! Every count is checked against its bound before a byte of the file is read.
6use anyhow::{bail, ensure, Context, Result};
7use serde_json::{json, Value};
8use std::time::Duration;
9use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt};
10
11/// A daemon command or receive-job subcommand line, including its LF.
12pub const MAX_COMMAND_LINE: usize = 1024;
13/// A control file is a few short lines; LPRng and BSD lpd keep them well under this.
14pub const MAX_CONTROL_FILE: u64 = 64 * 1024;
15/// Default total size of the data files in one job.
16pub const DEFAULT_MAX_JOB_BYTES: u64 = 16 * 1024 * 1024;
17/// Upper bound on the configurable job size.
18pub const MAX_JOB_BYTES_LIMIT: u64 = 256 * 1024 * 1024;
19/// Data files one control file may reference (dfA..dfZ, dfa..dfz).
20pub const MAX_FILES_PER_JOB: usize = 52;
21/// Text of a data file shown to a handler.
22pub const MAX_PREVIEW: usize = 64 * 1024;
23/// Bytes of a queue listing or removal reply the client accepts.
24pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
25/// Connect, write, acknowledgement and per-read deadline.
26pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
27
28/// Daemon command codes (RFC 1179 section 5).
29pub 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;
34/// Receive-job subcommand codes (RFC 1179 section 6).
35pub const SUB_ABORT: u8 = 0x01;
36pub const SUB_CONTROL_FILE: u8 = 0x02;
37pub const SUB_DATA_FILE: u8 = 0x03;
38
39/// Read one LF-terminated line, refusing past `MAX_COMMAND_LINE`. `None` at a clean EOF.
40pub 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
75/// Read a file body of exactly `count` bytes and its terminating NUL.
76pub 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
94/// A queue name or job-list token: printable, no whitespace, short.
95pub fn valid_token(token: &str) -> bool {
96    !token.is_empty() && token.len() <= 128 && token.bytes().all(|b| b.is_ascii_graphic())
97}
98
99/// Split a command line's operands on spaces and tabs.
100pub 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
108/// `cfA123host` / `dfA123host`: the three-digit job number and the originating host.
109pub 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/// A parsed control file.
119#[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    /// (format letter, data file name, source file name if an N line preceded it)
129    pub prints: Vec<(char, String, Option<String>)>,
130    pub unlink: Vec<String>,
131}
132
133/// Print-file format letters RFC 1179 defines.
134const 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                // Fonts, indent, width, symlink data and anything newer: kept out of the event.
174                _ => {}
175            }
176        }
177        Ok(control)
178    }
179
180    /// Distinct data files the job needs before it is complete.
181    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
192/// Text of a data file for a handler: the UTF-8 prefix, or null for binary content.
193pub 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    // Binary when it holds a control character other than line breaks, tabs and form feeds.
203    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
212/// One line of a queue listing, kept free of control characters.
213pub 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
224/// Render a queue listing in the BSD/LPRng shape clients expect.
225pub 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}