M10.5: load driver options for CUPS printer instances (#1226)

Use the queue component of a validated CUPS destination to locate its PPD,
while preserving the full queue/instance name when querying lpoptions.
Cover instance defaults and command arguments with synthetic PPDs and a
stand-in process, retaining path validation and the existing public API.

Co-authored-by: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com>
This commit is contained in:
srt8hellcat2-ui 2026-10-12 01:33:29 -04:00 committed by GitHub
parent 486b1bbcb1
commit 4812e49992
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 161 additions and 1 deletions

View File

@ -3,9 +3,58 @@
pub use craft_print::{
Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, Scaling, lp_args, lpstat_command, parse_lpoptions, parse_lpstat,
parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printer_options, printers, printers_parsed,
parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers, printers_parsed,
};
/// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults);
/// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it
/// when the user asks, not every frame.
pub fn printer_options(printer: &str) -> Vec<PrinterOption> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
printer_options_via(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions"))
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
{
craft_print::printer_options(printer)
}
}
/// Read a destination's options with the PPD directory and command supplied, so tests need
/// neither a system queue nor a physical printer. CUPS instances share their queue's PPD but
/// have their own defaults, queried with the complete `queue/instance` destination.
#[cfg(all(unix, not(target_arch = "wasm32")))]
pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut lpoptions: std::process::Command) -> Vec<PrinterOption> {
let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i)));
// Retain the conservative name checks, applied to each component. Neither component is
// a path: reject dot components, backslashes and any additional separator before I/O.
let valid = |name: &str| {
!name.is_empty()
&& name.len() <= 64
&& !matches!(name, "." | "..")
&& name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"'))
};
if !valid(queue) || instance.is_some_and(|name| !valid(name)) {
return Vec::new();
}
const PPD_CAP: u64 = 16 << 20;
let ppd = std::fs::File::open(ppd_dir.join(format!("{queue}.ppd"))).ok().and_then(|f| {
use std::io::Read as _;
let mut bytes = Vec::new();
f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes)
});
let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default();
let current = lpoptions.args(["-p", printer, "-l"]).output().map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
for (key, value) in current {
if let Some(o) = options.iter_mut().find(|o| o.key == key)
&& o.choices.iter().any(|(c, _)| *c == value)
{
o.default = value;
}
}
options
}
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string()))
}

View File

@ -458,6 +458,117 @@ fn printer_option_defaults_and_job_arguments() {
assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name");
}
#[cfg(all(unix, not(target_arch = "wasm32")))]
mod cups_options {
use crate::spool;
const RECORD: &str = "PDFCRAFT_LPOPTIONS_TEST_RECORD";
const ANSWER: &str = "PDFCRAFT_LPOPTIONS_TEST_ANSWER";
/// The child process stands in for lpoptions, recording its real arguments.
#[test]
fn stand_in_for_lpoptions() {
let Some(record) = std::env::var_os(RECORD) else { return };
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
println!("{}", std::env::var(ANSWER).unwrap());
}
fn command(record: &std::path::Path, answer: &str) -> std::process::Command {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "tests::cups_options::stand_in_for_lpoptions", "--nocapture", "--"]).env(RECORD, record).env(ANSWER, answer);
c
}
fn folder() -> std::path::PathBuf {
use std::io::Read as _;
use std::os::unix::fs::DirBuilderExt as _;
let mut random = std::fs::File::open("/dev/urandom").unwrap();
for _ in 0..32 {
let mut nonce = [0; 16];
random.read_exact(&mut nonce).unwrap();
let path = std::env::temp_dir().join(format!("pdfcraft-cups-options-{:032x}", u128::from_be_bytes(nonce)));
// Atomic creation, private permissions from the outset; never reuse an existing
// path (including a symlink) in the shared temporary directory.
match std::fs::DirBuilder::new().mode(0o700).create(&path) {
Ok(()) => return path,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => panic!("create private CUPS test directory: {e}"),
}
}
panic!("could not create a unique CUPS test directory");
}
#[test]
fn instances_read_the_queue_ppd_and_their_own_defaults() {
use std::os::unix::fs::PermissionsExt as _;
let dir = folder();
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
// One synthetic queue PPD; there is deliberately no Office/ subdirectory.
std::fs::write(
dir.join("Office.ppd"),
"*OpenUI *InputSlot/Paper tray: PickOne\n*DefaultInputSlot: Auto\n*InputSlot Auto/Automatic: \"\"\n*InputSlot Tray2/Tray 2: \"\"\n*CloseUI: *InputSlot\n",
)
.unwrap();
let boundary_queue = "Q".repeat(64);
let boundary_instance = format!("Office/{}", "I".repeat(64));
std::fs::copy(dir.join("Office.ppd"), dir.join(format!("{boundary_queue}.ppd"))).unwrap();
for (destination, current, expected) in [
("Office", "Auto", "Auto"),
("Office/duplex", "Tray2", "Tray2"),
("Office/draft", "Auto", "Auto"),
("Office/unknown-default", "Bogus", "Auto"),
(boundary_queue.as_str(), "Tray2", "Tray2"),
(boundary_instance.as_str(), "Tray2", "Tray2"),
] {
let options = spool::printer_options_via(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")));
assert_eq!(options.len(), 1, "{destination}: the queue's PPD must be read");
assert_eq!(options[0].default, expected, "{destination}: only valid instance choices override the PPD default");
assert_eq!(options[0].choices, [("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]);
assert_eq!(std::fs::read_to_string(dir.join("args")).unwrap(), format!("-p\n{destination}\n-l"));
}
let job = spool::Job { printer: Some("Office/duplex".into()), ..Default::default() };
assert_eq!(&spool::lp_args(&job)[..2], ["-d", "Office/duplex"]);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn invalid_destinations_are_rejected_before_running_lpoptions() {
let dir = folder();
let long_queue = "Q".repeat(65);
let long_queue_instance = format!("{long_queue}/duplex");
let long_instance = format!("Office/{}", "I".repeat(65));
for destination in [
"",
".",
"..",
"/Office",
"Office/",
"Office/a/b",
"../../etc/passwd",
"./duplex",
"../duplex",
"Office/.",
"Office/..",
"Office\\duplex",
"Office/\\duplex",
"Office/with space",
"Office/line\nbreak",
"Office/quote\"",
"Office/equals=",
"Office/a:b",
"Office/\0",
long_queue.as_str(),
long_queue_instance.as_str(),
long_instance.as_str(),
] {
assert!(spool::printer_options_via(destination, &dir, command(&dir, "InputSlot: *Auto")).is_empty(), "{destination:?}");
assert!(!dir.join("args").exists(), "{destination:?}: rejected before spawning");
}
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn a_scaled_appearance_is_not_scaled_twice() {
let doc = fixture(1);