1use crate::files::{Dd, Dds};
7use crate::unit::Event;
8use crate::vocab::OpenMode;
9use std::fs::OpenOptions;
10use std::io::Write;
11
12pub const DD: &str = "PRINTER";
13pub const ROUTINES: &[&str] = &["SYSTEM", "C$SYSTEM"];
14
15#[derive(Debug, PartialEq, Eq)]
17pub struct Job {
18 pub files: Vec<String>,
19}
20
21struct Options {
24 valued: &'static str,
25 flags: &'static str,
26}
27
28const LP: Options = Options { valued: "dhnoqtHPU", flags: "Ecms" };
29const LPR: Options = Options { valued: "#CHJPTUo", flags: "Ehlmpqr" };
30
31fn plain(word: &str) -> bool {
34 !word.starts_with('#') && word.chars().all(|c| c.is_ascii_alphanumeric() || "-_.,/=:+@%#".contains(c))
35}
36
37fn quoted_variable(word: &str) -> bool {
40 let Some(inner) = word.strip_prefix("\"$").and_then(|w| w.strip_suffix('"')) else { return false };
41 let name = inner.strip_prefix('{').and_then(|n| n.strip_suffix('}')).unwrap_or(inner);
42 name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
43}
44
45impl Job {
46 pub fn parse(command: &str) -> Option<Job> {
49 let command = command.split('\0').next().unwrap_or_default();
50 let mut words = command.split(' ').filter(|w| !w.is_empty());
51 let program = words.next().filter(|w| plain(w))?;
52 let options = match program.rsplit('/').next()? {
53 "lp" => LP,
54 "lpr" => LPR,
55 _ => return None,
56 };
57 let mut files = Vec::new();
58 let mut in_options = true;
59 while let Some(word) = words.next() {
60 if word == "-" {
61 return None;
62 }
63 if in_options && word == "--" {
64 in_options = false;
65 continue;
66 }
67 let Some(letters) = word.strip_prefix('-').filter(|_| in_options) else {
68 if !plain(word) {
69 return None;
70 }
71 files.push(word.to_owned());
72 continue;
73 };
74 for (at, letter) in letters.char_indices() {
75 if options.valued.contains(letter) {
76 let value = match &letters[at + 1..] {
77 "" => words.next()?,
78 attached => attached,
79 };
80 if !plain(value) && !quoted_variable(value) {
81 return None;
82 }
83 break;
84 }
85 if !options.flags.contains(letter) {
86 return None;
87 }
88 }
89 }
90 (!files.is_empty()).then_some(Job { files })
91 }
92}
93
94pub fn print(dds: &Dds, printer: &Dd, job: &Job, notify: &mut dyn FnMut(Event<'_>)) -> Result<(), String> {
98 let named = job
99 .files
100 .iter()
101 .map(|file| {
102 let name = file.to_ascii_uppercase();
103 dds.get(&name).map(|dd| (name.clone(), dd)).ok_or_else(|| format!("{file}: no DD {name}"))
104 })
105 .collect::<Result<Vec<_>, _>>()?;
106 let mut bytes = Vec::new();
107 for (name, dd) in &named {
108 notify(Event::Open { dd: name, mode: OpenMode::Input, path: &dd.path });
109 let read = std::fs::read(&dd.path);
110 notify(Event::Close { dd: name, path: &dd.path });
111 bytes.extend(read.map_err(|e| format!("DD {name} {}: {e}", dd.path.display()))?);
112 }
113 notify(Event::Open { dd: DD, mode: OpenMode::Extend, path: &printer.path });
114 let written = OpenOptions::new().create(true).append(true).open(&printer.path).and_then(|mut f| f.write_all(&bytes));
115 notify(Event::Close { dd: DD, path: &printer.path });
116 written.map_err(|e| format!("DD {DD} {}: {e}", printer.path.display()))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 fn files(command: &str) -> Option<Vec<String>> {
124 Job::parse(command).map(|j| j.files)
125 }
126
127 #[test]
128 fn lp_and_lpr_name_the_files_they_print() {
129 let padded = format!("lp -d HP_LaserJet -o cpi=10 -o lpi=6 report.txt{}", " ".repeat(40));
130 assert_eq!(files(&padded), Some(vec!["report.txt".into()]));
131 assert_eq!(files("lpr -P office -#2 -h a.lst b.lst"), Some(vec!["a.lst".into(), "b.lst".into()]));
132 assert_eq!(files("/usr/bin/lp -dqueue -n 3 -- -odd.txt\0garbage"), Some(vec!["-odd.txt".into()]));
133 assert_eq!(files("lp -cs report.txt"), Some(vec!["report.txt".into()]));
134 assert_eq!(files("lp -d \"$CH7ASG02_PRINTER\" -o cpi=10 -o lpi=6 report.txt"), Some(vec!["report.txt".into()]));
135 assert_eq!(files("lpr -P\"${QUEUE}\" report.txt"), Some(vec!["report.txt".into()]));
136 }
137
138 #[test]
139 fn a_command_the_shell_would_do_more_with_is_not_a_print() {
140 for command in [
141 "lp -d x;rm -rf ~ report.txt",
142 "lp report.txt | mail me",
143 "lp -d $(id) report.txt",
144 "lp \"report.txt\"",
145 "lp report.txt\nrm report.txt",
146 "lp report.txt #note",
147 "lp -d",
148 "lp -d ;x report.txt",
149 "$(id)/lp report.txt",
150 "lp -d \"$(id)\" report.txt",
151 "lp -d \"$Q;id\" report.txt",
152 "lp -d \"$1\" report.txt",
153 "lp -d '$Q' report.txt",
154 "lp -d $Q report.txt",
155 "lp \"$REPORT\"",
156 "lp -c\"$Q\" report.txt",
157 ] {
158 assert_eq!(files(command), None, "{command}");
159 }
160 }
161
162 #[test]
163 fn anything_else_is_not_a_print() {
164 for command in ["lpstat -p", "lp", "lp -d office", "lp -", "lp -i 12 -n 2", "lp -z report.txt", "lpr -d office report.txt", "cat report.txt", "", " "] {
165 assert_eq!(files(command), None, "{command:?}");
166 }
167 }
168
169 fn scratch(name: &str) -> std::path::PathBuf {
170 let dir = std::env::temp_dir().join(format!("iw-vprinter-{name}-{}", std::process::id()));
171 let _ = std::fs::remove_dir_all(&dir);
172 std::fs::create_dir_all(&dir).unwrap();
173 dir
174 }
175
176 #[test]
177 fn a_job_appends_each_file_and_reaches_only_given_dds() {
178 let dir = scratch("print");
179 std::fs::write(dir.join("report"), b"PAYROLL\n").unwrap();
180 let printer = dir.join("printer.prn");
181 let dds = Dds::new(&[format!("REPORT.TXT={}", dir.join("report").display()), format!("PRINTER={}", printer.display())], false).unwrap();
182 let dd = dds.get(DD).unwrap();
183 let mut heard = Vec::new();
184 let job = Job::parse("lp -d office report.txt").unwrap();
185 print(&dds, &dd, &job, &mut |e| heard.push(matches!(e, Event::Open { .. }))).unwrap();
186 print(&dds, &dd, &job, &mut |_| {}).unwrap();
187 assert_eq!(std::fs::read(&printer).unwrap(), b"PAYROLL\nPAYROLL\n");
188 assert_eq!(heard, [true, false, true, false]);
189 let refused = print(&dds, &dd, &Job::parse("lp report.txt /etc/passwd").unwrap(), &mut |_| {});
190 assert_eq!(refused, Err("/etc/passwd: no DD /ETC/PASSWD".to_string()));
191 assert_eq!(std::fs::read(&printer).unwrap(), b"PAYROLL\nPAYROLL\n", "nothing prints when one file has no DD");
192 }
193}