Skip to main content

ironwork_rt/
virtual_printer.rs

1//! The virtual printer. In a run given DD PRINTER, a CALL of SYSTEM or C$SYSTEM whose command prints
2//! files with lp or lpr appends those files to that DD, and nothing runs on the host. Each file the
3//! command names is a DD, as an ASSIGN literal names one, so the run still reaches only the files the
4//! operator gave it.
5
6use 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/// The files an lp or lpr command prints, in order.
16#[derive(Debug, PartialEq, Eq)]
17pub struct Job {
18    pub files: Vec<String>,
19}
20
21/// The option letters CUPS documents for a command: those that take a value, attached or as the
22/// next word, and those that stand alone.
23struct 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
31/// A word the shell passes as it stands: any other character has it expand, quote, redirect or
32/// chain commands, and a leading `#` starts a comment.
33fn plain(word: &str) -> bool {
34    !word.starts_with('#') && word.chars().all(|c| c.is_ascii_alphanumeric() || "-_.,/=:+@%#".contains(c))
35}
36
37/// `"$NAME"` or `"${NAME}"`: one word holding the variable's value, which the shell neither splits
38/// nor runs, so a program can pass an option's value through the environment.
39fn 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    /// The job `command` asks for when it is lp or lpr, with options CUPS documents, naming at
47    /// least one file; `None` for any other command, including one that reads standard input.
48    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
94/// Prints `job` on `printer`: each file's DD, read whole, appended to it in order. Every file
95/// needs a DD before anything prints, as lp prints nothing when it cannot read one. `notify`
96/// hears each DD opened and closed.
97pub 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}