use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::time::Duration;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt};
pub const MAX_COMMAND_LINE: usize = 1024;
pub const MAX_CONTROL_FILE: u64 = 64 * 1024;
pub const DEFAULT_MAX_JOB_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_JOB_BYTES_LIMIT: u64 = 256 * 1024 * 1024;
pub const MAX_FILES_PER_JOB: usize = 52;
pub const MAX_PREVIEW: usize = 64 * 1024;
pub const MAX_REPLY_BYTES: usize = 1024 * 1024;
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
pub const CMD_PRINT_WAITING: u8 = 0x01;
pub const CMD_RECEIVE_JOB: u8 = 0x02;
pub const CMD_QUEUE_SHORT: u8 = 0x03;
pub const CMD_QUEUE_LONG: u8 = 0x04;
pub const CMD_REMOVE_JOBS: u8 = 0x05;
pub const SUB_ABORT: u8 = 0x01;
pub const SUB_CONTROL_FILE: u8 = 0x02;
pub const SUB_DATA_FILE: u8 = 0x03;
pub async fn read_line<R: AsyncBufRead + Unpin>(
reader: &mut R,
deadline: Duration,
) -> Result<Option<Vec<u8>>> {
tokio::time::timeout(deadline, async {
let mut line = Vec::new();
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
if line.is_empty() {
return Ok(None);
}
bail!("LPD peer closed mid-command");
}
let (chunk, found) = match available.iter().position(|b| *b == b'\n') {
Some(i) => (&available[..=i], true),
None => (available, false),
};
ensure!(
line.len() + chunk.len() <= MAX_COMMAND_LINE,
"LPD command line exceeds {MAX_COMMAND_LINE} bytes"
);
line.extend_from_slice(chunk);
let taken = chunk.len();
reader.consume(taken);
if found {
line.pop();
return Ok(Some(line));
}
}
})
.await
.context("LPD read deadline")?
}
pub async fn read_file<R: AsyncRead + Unpin>(
reader: &mut R,
count: u64,
deadline: Duration,
) -> Result<Vec<u8>> {
let mut body = vec![0u8; usize::try_from(count)?];
tokio::time::timeout(deadline, async {
reader.read_exact(&mut body).await?;
let mut nul = [0u8; 1];
reader.read_exact(&mut nul).await?;
ensure!(nul[0] == 0, "LPD file not terminated by a NUL byte");
Ok(())
})
.await
.context("LPD file transfer deadline")??;
Ok(body)
}
pub fn valid_token(token: &str) -> bool {
!token.is_empty() && token.len() <= 128 && token.bytes().all(|b| b.is_ascii_graphic())
}
pub fn operands(line: &[u8]) -> Vec<String> {
String::from_utf8_lossy(line)
.split([' ', '\t'])
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
pub fn job_number(name: &str) -> Option<(String, String)> {
let rest = name.get(3..)?;
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
Some((digits.clone(), rest[digits.len()..].to_string()))
}
#[derive(Debug, Clone, Default)]
pub struct ControlFile {
pub host: Option<String>,
pub user: Option<String>,
pub job_name: Option<String>,
pub title: Option<String>,
pub class: Option<String>,
pub banner_user: Option<String>,
pub mail: Option<String>,
pub prints: Vec<(char, String, Option<String>)>,
pub unlink: Vec<String>,
}
const FORMATS: &str = "cdfglnoprtvz";
impl ControlFile {
pub fn parse(bytes: &[u8]) -> Result<Self> {
let text = std::str::from_utf8(bytes).context("LPD control file is not UTF-8")?;
let mut control = ControlFile::default();
let mut source: Option<String> = None;
for line in text.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
let mut chars = line.chars();
let Some(code) = chars.next() else { continue };
let value = chars.as_str().to_string();
ensure!(
!crate::utils::sanitize::has_controls(&value),
"LPD control line contains control characters"
);
match code {
'H' => control.host = Some(value),
'P' => control.user = Some(value),
'J' => control.job_name = Some(value),
'T' => control.title = Some(value),
'C' => control.class = Some(value),
'L' => control.banner_user = Some(value),
'M' => control.mail = Some(value),
'N' => source = Some(value),
'U' => control.unlink.push(value),
c if FORMATS.contains(c) => {
ensure!(
value.starts_with("df"),
"LPD print line must name a data file"
);
if !control.prints.iter().any(|(_, name, _)| *name == value) {
ensure!(
control.prints.len() < MAX_FILES_PER_JOB,
"LPD job names too many data files"
);
}
control.prints.push((c, value, source.take()));
}
_ => {}
}
}
Ok(control)
}
pub fn data_files(&self) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for (_, name, _) in &self.prints {
if !names.contains(name) {
names.push(name.clone());
}
}
names
}
}
pub fn preview(bytes: &[u8]) -> (Value, bool) {
let shown = &bytes[..bytes.len().min(MAX_PREVIEW)];
let text = match std::str::from_utf8(shown) {
Ok(text) => text,
Err(e) if e.error_len().is_none() => {
std::str::from_utf8(&shown[..e.valid_up_to()]).unwrap_or("")
}
Err(_) => return (Value::Null, true),
};
let binary =
crate::utils::sanitize::has_controls(&text.replace(['\n', '\r', '\t', '\x0c'], ""));
if binary {
(Value::Null, true)
} else {
(json!(text), false)
}
}
pub fn clean(text: &str, limit: usize) -> String {
let mut out = String::new();
for c in crate::utils::sanitize::line_field(text).chars() {
if out.len() + c.len_utf8() > limit {
break;
}
out.push(c);
}
out
}
pub fn render_queue(queue: &str, long: bool, status: Option<&str>, jobs: &[Value]) -> String {
let mut out = String::new();
out.push_str(&clean(status.unwrap_or(&format!("{queue} is ready")), 200));
out.push('\n');
if jobs.is_empty() {
out.push_str("no entries\n");
return out;
}
if !long {
out.push_str("Rank Owner Job Files Total Size\n");
}
for (i, job) in jobs.iter().enumerate() {
let rank = job["rank"].as_str().map(str::to_string).unwrap_or_else(|| {
if i == 0 {
"active".into()
} else {
format!("{}", i + 1)
}
});
let owner = clean(job["owner"].as_str().unwrap_or("unknown"), 32);
let id = job["job_id"].as_u64().unwrap_or(i as u64 + 1);
let files = clean(job["files"].as_str().unwrap_or("(stdin)"), 120);
let size = job["size"].as_u64().unwrap_or(0);
if long {
out.push_str(&format!(
"\n{owner}: {rank:<28}[job {id:03}]\n {files:<32}{size} bytes\n"
));
} else {
out.push_str(&format!(
"{rank:<7}{owner:<11}{id:<5}{files:<38}{size} bytes\n"
));
}
}
out
}