Browse / github.com/storytold/craft-libs / patch
fix(print): list printers and spool without the CUPS command-line tools
fix(print): list printers and spool without the CUPS command-line tools On Linux, printer discovery shells out to `lpstat` and spooling to `lp`. When the CUPS command-line tools are absent — most visibly in the Flatpak sandbox, which ships neither — the spawn failures collapse into an empty printer list, and submission has no transport other than `lp`. At the shipped pin `3d37da38` that is `crates/print/src/lib.rs:366-373` (`printers()`: an `lpstat` spawn failure maps through `.ok()` to no queues) and `lib.rs:460-492` (`submit_with_progress()`: `lp` is the only transport; without a default destination it refuses). The user-visible result is a print dialog with not
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ahmedhosnypro (imported from pull request #156, open)
- Base
8f5a2831614e632f91ad367f7af2a6e5b61b9529- Size
- +1649 −19 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bdd4998fad5861c1006c7d5f
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/print/Cargo.toml crates/print/src/ipp.rs crates/print/src/lib.rs crates/print/src/portal.rs crates/print/src/tests.rs crates/print/src/tests/ipp.rs crates/print/src/tests/portal.rs crates/print/src/tests/route.rs
@@ -135,6 +135,126 @@ dependencies = [ "libloading", ] +[[package]]+name = "async-broadcast"+version = "0.7.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-channel"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"+dependencies = [+ "concurrent-queue",+ "event-listener-strategy",+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "async-executor"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"+dependencies = [+ "async-task",+ "concurrent-queue",+ "fastrand",+ "futures-lite",+ "pin-project-lite",+ "slab",+]++[[package]]+name = "async-io"+version = "2.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"+dependencies = [+ "autocfg",+ "cfg-if",+ "concurrent-queue",+ "futures-io",+ "futures-lite",+ "parking",+ "polling",+ "rustix 1.1.5",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-lock"+version = "3.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"+dependencies = [+ "event-listener",+ "event-listener-strategy",+ "pin-project-lite",+]++[[package]]+name = "async-process"+version = "2.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"+dependencies = [+ "async-channel",+ "async-io",+ "async-lock",+ "async-signal",+ "async-task",+ "blocking",+ "cfg-if",+ "event-listener",+ "futures-lite",+ "rustix 1.1.5",+]++[[package]]+name = "async-recursion"+version = "1.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]++[[package]]+name = "async-signal"+version = "0.2.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"+dependencies = [+ "async-io",+ "async-lock",+ "atomic-waker",+ "cfg-if",+ "futures-core",+ "futures-io",+ "rustix 1.1.5",+ "signal-hook-registry",+ "slab",+ "windows-sys 0.61.2",+]++[[package]]+name = "async-task"+version = "4.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"+ [[package]] name = "async-trait" version = "0.1.92"@@ -254,6 +374,19 @@ dependencies = [ "objc2 0.6.5", ] +[[package]]+name = "blocking"+version = "1.7.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"+dependencies = [+ "async-channel",+ "async-task",+ "futures-io",+ "futures-lite",+ "piper",+]+ [[package]] name = "brotli" version = "8.0.4"@@ -623,6 +756,7 @@ dependencies = [ "tempfile", "winprint", "winsafe",+ "zbus", ] [[package]]@@ -1032,6 +1166,12 @@ dependencies = [ "simdutf8", ] +[[package]]+name = "endi"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"+ [[package]] name = "enum-map" version = "2.7.3"@@ -1052,6 +1192,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]]+name = "enumflags2"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"+dependencies = [+ "enumflags2_derive",+ "serde",+]++[[package]]+name = "enumflags2_derive"+version = "0.7.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 2.0.119",+]+ [[package]] name = "epaint" version = "0.36.2"@@ -1114,6 +1275,26 @@ dependencies = [ "num-traits", ] +[[package]]+name = "event-listener"+version = "5.4.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"+dependencies = [+ "parking",+ "pin-project-lite",+]++[[package]]+name = "event-listener-strategy"+version = "0.5.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"+dependencies = [+ "event-listener",+ "pin-project-lite",+]+ [[package]] name = "fastrand" version = "2.5.0"@@ -1299,6 +1480,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]]+name = "futures-lite"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"+dependencies = [+ "fastrand",+ "futures-core",+ "futures-io",+ "parking",+ "pin-project-lite",+]+ [[package]] name = "futures-sink" version = "0.3.34"@@ -2209,6 +2403,15 @@ dependencies = [ "libc", ] +[[package]]+name = "memoffset"+version = "0.9.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"+dependencies = [+ "autocfg",+]+ [[package]] name = "mime" version = "0.3.17"@@ -2758,6 +2961,22 @@ dependencies = [ "num-traits", ] +[[package]]+name = "ordered-stream"+version = "0.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"+dependencies = [+ "futures-core",+ "pin-project-lite",+]++[[package]]+name = "parking"+version = "2.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"+ [[package]] name = "parking_lot" version = "0.12.5"@@ -2915,6 +3134,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]]+name = "piper"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"+dependencies = [+ "atomic-waker",+ "fastrand",+ "futures-io",+]+ [[package]] name = "pkg-config" version = "0.3.34"@@ -3667,6 +3897,17 @@ dependencies = [ "serde_json", ] +[[package]]+name = "serde_repr"+version = "0.1.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"+dependencies = [+ "proc-macro2",+ "quote",+ "syn 3.0.6",+]+ [[package]] name = "serde_spanned" version = "1.1.2"@@ -3716,6 +3957,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]]+name = "signal-hook-registry"+version = "1.4.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"+dependencies = [+ "errno",+ "libc",+]+ [[package]] name = "signature" version = "2.2.0"@@ -4561,6 +4812,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]]+name = "uds_windows"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"+dependencies = [+ "memoffset",+ "tempfile",+ "windows-sys 0.61.2",+]+ [[package]] name = "unicode-bidi" version = "0.3.18"@@ -4681,6 +4943,11 @@ name = "uuid" version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"+dependencies = [+ "js-sys",+ "serde_core",+ "wasm-bindgen",+] [[package]] name = "vello_common"@@ -5736,6 +6003,76 @@ dependencies = [ "synstructure", ] +[[package]]+name = "zbus"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"+dependencies = [+ "async-broadcast",+ "async-executor",+ "async-io",+ "async-lock",+ "async-process",+ "async-recursion",+ "async-task",+ "async-trait",+ "blocking",+ "enumflags2",+ "event-listener",+ "futures-core",+ "futures-lite",+ "hex",+ "libc",+ "ordered-stream",+ "rustix 1.1.5",+ "serde",+ "serde_repr",+ "tracing",+ "uds_windows",+ "uuid",+ "windows-sys 0.61.2",+ "winnow",+ "zbus_macros",+ "zbus_names",+ "zvariant",+]++[[package]]+name = "zbus_macros"+version = "5.19.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.6",+ "zbus_names",+ "zvariant",+ "zvariant_utils",+]++[[package]]+name = "zbus_names"+version = "4.3.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"+dependencies = [+ "serde",+ "winnow",+ "zvariant",+]++[[package]]+name = "zcheapstr"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"+dependencies = [+ "serde",+]+ [[package]] name = "zerocopy" version = "0.8.62"@@ -5856,3 +6193,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ]++[[package]]+name = "zvariant"+version = "5.15.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"+dependencies = [+ "endi",+ "enumflags2",+ "serde",+ "winnow",+ "zcheapstr",+ "zvariant_derive",+ "zvariant_utils",+]++[[package]]+name = "zvariant_derive"+version = "5.15.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"+dependencies = [+ "proc-macro-crate",+ "proc-macro2",+ "quote",+ "syn 3.0.6",+ "zvariant_utils",+]++[[package]]+name = "zvariant_utils"+version = "4.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"+dependencies = [+ "proc-macro2",+ "quote",+ "serde",+ "syn 3.0.6",+ "winnow",+]@@ -31,6 +31,13 @@ roxmltree = "0.20" # Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs). miniz_oxide = "0.8" +[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies]+# The XDG print portal on the session bus, for installs with no CUPS server to talk IPP to+# (MIT OR Apache-2.0); pinned to its major version, review before bumping.+zbus = "5"+# The job's private file, handed to the portal as a file descriptor.+tempfile = "3"+ [dev-dependencies] tempfile = "3" roxmltree = "0.20"new file mode 100644@@ -0,0 +1,519 @@+//! IPP/1.1 straight to the local CUPS server: printer discovery (`CUPS-Get-Printers`,+//! `CUPS-Get-Default`) and job submission (`Print-Job`) over HTTP/1.1, for when `lpstat`/`lp`+//! are missing (a sandboxed install, where the print dialog would otherwise have no printers).+//! Implemented from the public IPP encoding (RFC 2910/8010) and semantics (RFC 8011) and+//! CUPS's IPP documentation; the scheduler is reached on its socket or loopback port.++use super::{Duplex, Error, Job, Printer, Scaling};+use std::io::{Read, Write};+use std::net::{SocketAddr, TcpStream};+use std::os::unix::net::UnixStream;+use std::path::PathBuf;+use std::sync::atomic::{AtomicU32, Ordering};+use std::time::Duration;++/// Where the local scheduler listens: its socket, else its loopback port.+const CUPS_SOCKET: &str = "/run/cups/cups.sock";++/// Caps on what the server may hand back: a reply is read into memory before it is parsed.+const MAX_BODY: usize = 1 << 20;+const MAX_HEADERS: usize = 8 << 10;+const MAX_PRINTERS: usize = 256;+const MAX_NAME: usize = 256;+/// A job's title on the wire; IPP values are u16-length, and no real title is this long.+const MAX_JOB_NAME: usize = 255;++/// A wedged scheduler must not hang the dialog; a job may carry megabytes and is given longer.+const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);+const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);+const JOB_TIMEOUT: Duration = Duration::from_secs(30);++/// IPP operation codes: the standard Print-Job, and CUPS's discovery extensions.+const OPERATION_PRINT_JOB: u16 = 0x0002;+const OPERATION_CUPS_GET_DEFAULT: u16 = 0x4001;+const OPERATION_CUPS_GET_PRINTERS: u16 = 0x4002;++/// IPP value tags and group delimiters (RFC 8010 §5.1); the ones this client sends or reads.+const GROUP_OPERATION: u8 = 0x01;+const GROUP_JOB: u8 = 0x02;+const GROUP_END: u8 = 0x03;+const GROUP_PRINTER: u8 = 0x04;+const TAG_INTEGER: u8 = 0x21;+const TAG_BOOLEAN: u8 = 0x22;+const TAG_NAME: u8 = 0x42;+const TAG_KEYWORD: u8 = 0x44;+const TAG_URI: u8 = 0x45;+const TAG_CHARSET: u8 = 0x47;+const TAG_LANGUAGE: u8 = 0x48;+const TAG_MIME: u8 = 0x49;++static REQUEST_ID: AtomicU32 = AtomicU32::new(1);++/// How the scheduler is reached: its unix socket, else its loopback port.+pub(crate) enum Server {+ Unix(PathBuf),+ Tcp(SocketAddr),+}++fn endpoints() -> [Server; 2] {+ [Server::Unix(PathBuf::from(CUPS_SOCKET)), Server::Tcp(([127, 0, 0, 1], 631).into())]+}++/// Whether the local CUPS server is reachable: its socket, else its loopback port. A missing+/// scheduler is a normal `false`, not an error.+pub fn cups_reachable() -> bool {+ endpoints().iter().any(reachable)+}++pub(crate) fn reachable(server: &Server) -> bool {+ connect(server).is_ok()+}++fn connect(server: &Server) -> std::io::Result<Stream> {+ match server {+ Server::Unix(path) => UnixStream::connect(path).map(Stream::Unix),+ // A loopback connect either answers promptly or the server is not there.+ Server::Tcp(addr) => TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).map(|s| {+ let _ = s.set_nodelay(true);+ Stream::Tcp(s)+ }),+ }+}++/// A connection to the scheduler.+enum Stream {+ Unix(UnixStream),+ Tcp(TcpStream),+}++impl Stream {+ fn set_timeouts(&self, timeout: Duration) -> std::io::Result<()> {+ let (read, write) = match self {+ Stream::Unix(s) => (s.set_read_timeout(Some(timeout)), s.set_write_timeout(Some(timeout))),+ Stream::Tcp(s) => (s.set_read_timeout(Some(timeout)), s.set_write_timeout(Some(timeout))),+ };+ read.and(write)+ }+}++impl Read for Stream {+ fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {+ match self {+ Stream::Unix(s) => s.read(buf),+ Stream::Tcp(s) => s.read(buf),+ }+ }+}++impl Write for Stream {+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {+ match self {+ Stream::Unix(s) => s.write(buf),+ Stream::Tcp(s) => s.write(buf),+ }+ }+ fn flush(&mut self) -> std::io::Result<()> {+ match self {+ Stream::Unix(s) => s.flush(),+ Stream::Tcp(s) => s.flush(),+ }+ }+}++/// The server's queues (CUPS-Get-Printers) and its default destination (CUPS-Get-Default), for+/// the Print dialog when `lpstat` is missing. A default the queue list lacks is appended, so+/// the dialog always has a selection to restore; a default that cannot be named still leaves+/// the queues usable.+pub fn printers() -> Result<Vec<Printer>, Error> {+ printers_at(&endpoints())+}++pub(crate) fn printers_at(endpoints: &[Server]) -> Result<Vec<Printer>, Error> {+ let queues = discover(endpoints, OPERATION_CUPS_GET_PRINTERS)?;+ let default = discover(endpoints, OPERATION_CUPS_GET_DEFAULT).ok().and_then(|names| names.into_iter().next());+ let mut printers: Vec<Printer> = Vec::new();+ for name in queues {+ if printers.len() >= MAX_PRINTERS {+ break;+ }+ if !printers.iter().any(|p| p.name == name) {+ printers.push(Printer { default: default.as_deref() == Some(name.as_str()), name, two_sided: None });+ }+ }+ if let Some(name) = default+ && !printers.iter().any(|p| p.name == name)+ {+ printers.push(Printer { name, default: true, two_sided: None });+ }+ Ok(printers)+}++/// The printer names a discovery reply carries: every queue (CUPS-Get-Printers), or the default+/// destination (CUPS-Get-Default).+fn discover(endpoints: &[Server], operation: u16) -> Result<Vec<String>, Error> {+ let reply = post(endpoints, "/", &discovery_request(operation), DISCOVERY_TIMEOUT, true)?;+ let parsed = parse_reply(&reply)?;+ if status_ok(parsed.status) { Ok(parsed.names) } else { Err(refused(parsed.status)) }+}++/// Submit the PDF as a Print-Job to the printer (the job's, else the server's default); the+/// reply is the job message, like `lp` prints it (`request id is Office-12`). The driver's own+/// job options (PPD keywords) are not sent on this path: they have no IPP equivalents without+/// reading the printer's capabilities, which is another request.+pub fn print_job(pdf: &[u8], job: &Job) -> Result<String, Error> {+ print_job_at(&endpoints(), pdf, job)+}++pub(crate) fn print_job_at(endpoints: &[Server], pdf: &[u8], job: &Job) -> Result<String, Error> {+ let queue = match &job.printer {+ Some(name) => name.clone(),+ None => discover(endpoints, OPERATION_CUPS_GET_DEFAULT)?+ .into_iter()+ .next()+ .ok_or_else(|| Error::Spool("the print server has no default printer; choose one in the dialog".into()))?,+ };+ if !is_queue_name(&queue) {+ return Err(Error::Spool(format!("not a printer name: {queue}")));+ }+ let reply = post(endpoints, &format!("/printers/{queue}"), &print_job_request(&queue, pdf, job), JOB_TIMEOUT, false)?;+ let parsed = parse_reply(&reply)?;+ if !status_ok(parsed.status) {+ return Err(refused(parsed.status));+ }+ Ok(match parsed.job_id {+ Some(id) => format!("request id is {queue}-{id}"),+ None => format!("the job was accepted by {queue}"),+ })+}++/// A CUPS queue name: ASCII letters, digits and `_`, `-`, `.` — what the scheduler accepts, and+/// what cannot break the printer URI's path.+fn is_queue_name(name: &str) -> bool {+ !name.is_empty() && name.len() <= MAX_NAME && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))+}++/// One IPP request and its reply body. Discovery may try each endpoint in turn; a job is given+/// to the first endpoint that accepts a connection, once: a retry could print it twice.+pub(crate) fn post(endpoints: &[Server], path: &str, body: &[u8], timeout: Duration, retry: bool) -> Result<Vec<u8>, Error> {+ let mut failed: Option<Error> = None;+ for server in endpoints {+ let mut stream = match connect(server) {+ Ok(stream) => stream,+ Err(e) => {+ failed = Some(Error::Spool(format!("the print server could not be reached: {e}")));+ continue;+ }+ };+ match exchange(&mut stream, path, body, timeout) {+ Ok(reply) => return Ok(reply),+ Err(e) => {+ failed = Some(e);+ if !retry {+ break;+ }+ }+ }+ }+ Err(failed.unwrap_or_else(|| Error::Spool("the print server could not be reached".into())))+}++/// Send one IPP request and read the reply's body.+fn exchange(stream: &mut Stream, path: &str, body: &[u8], timeout: Duration) -> Result<Vec<u8>, Error> {+ stream.set_timeouts(timeout).map_err(|e| Error::Spool(format!("the print server could not be set up: {e}")))?;+ let head = format!(+ "POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",+ body.len()+ );+ stream+ .write_all(head.as_bytes())+ .and_then(|()| stream.write_all(body))+ .and_then(|()| stream.flush())+ .map_err(|e| Error::Spool(format!("the request could not be sent to the print server: {e}")))?;+ read_reply(stream)+}++/// The reply's body: a strict status line and headers, then exactly `Content-Length` bytes. CUPS+/// answers with Content-Length; a chunked or unterminated reply is refused, not guessed.+fn read_reply(stream: &mut Stream) -> Result<Vec<u8>, Error> {+ let mut head = Vec::new();+ let mut byte = [0u8; 1];+ loop {+ if head.len() >= MAX_HEADERS {+ return Err(Error::Spool("the print server sent a header block that is too large".into()));+ }+ match stream.read(&mut byte) {+ Ok(0) => return Err(Error::Spool("the print server closed the connection before sending its reply".into())),+ Ok(_) => head.push(byte[0]),+ Err(e) => return Err(Error::Spool(format!("the print server's reply could not be read: {e}"))),+ }+ if head.ends_with(b"\r\n\r\n") {+ break;+ }+ }+ let head = String::from_utf8_lossy(&head);+ let Some(status_line) = head.lines().next() else { return Err(Error::Spool("the print server sent an unreadable reply".into())) };+ let code = status_line+ .strip_prefix("HTTP/")+ .and_then(|rest| rest.split_once(' '))+ .and_then(|(_, code)| code.split_whitespace().next())+ .and_then(|code| code.parse::<u16>().ok())+ .ok_or_else(|| Error::Spool("the print server sent an unreadable reply".into()))?;+ match code {+ 200..=299 => {}+ 401 => return Err(Error::Spool("the print server requires authentication".into())),+ 426 => return Err(Error::Spool("the print server only accepts encrypted connections".into())),+ _ => return Err(Error::Spool(format!("the print server refused the request (HTTP {code})"))),+ }+ let mut content_length: Option<usize> = None;+ for line in head.lines().skip(1) {+ // The empty line ends the header block; any other line without a colon is malformed.+ if line.is_empty() {+ continue;+ }+ let Some((name, value)) = line.split_once(':') else {+ return Err(Error::Spool("the print server sent an unreadable header".into()));+ };+ match name.trim().to_ascii_lowercase().as_str() {+ "content-length" => content_length = value.trim().parse::<usize>().ok(),+ // Only `identity` is not a transfer encoding to be decoded.+ "transfer-encoding" if !value.trim().eq_ignore_ascii_case("identity") => {+ return Err(Error::Spool("the print server sent a chunked reply".into()));+ }+ _ => {}+ }+ }+ let Some(len) = content_length else { return Err(Error::Spool("the print server sent no Content-Length".into())) };+ if len > MAX_BODY {+ return Err(Error::Spool(format!("the print server's reply is too large ({len} bytes)")));+ }+ let mut body = vec![0u8; len];+ stream.read_exact(&mut body).map_err(|e| Error::Spool(format!("the print server's reply was cut short: {e}")))?;+ Ok(body)+}++/// An IPP request being built: a version, an operation and a request id, then attributes, then+/// the document.+struct Request {+ bytes: Vec<u8>,+}++impl Request {+ fn new(operation: u16) -> Self {+ // IPP 1.1: version, operation, a request id (any nonzero value).+ let mut bytes = vec![1, 1];+ bytes.extend(operation.to_be_bytes());+ bytes.extend(REQUEST_ID.fetch_add(1, Ordering::Relaxed).max(1).to_be_bytes());+ Request { bytes }+ }++ fn group(&mut self, tag: u8) {+ self.bytes.push(tag);+ }++ fn end(&mut self) {+ self.bytes.push(GROUP_END);+ }++ fn attribute(&mut self, tag: u8, name: &str, value: &[u8]) {+ let (Ok(name_len), Ok(value_len)) = (u16::try_from(name.len()), u16::try_from(value.len())) else {+ return;+ };+ self.bytes.push(tag);+ self.bytes.extend(name_len.to_be_bytes());+ self.bytes.extend(name.as_bytes());+ self.bytes.extend(value_len.to_be_bytes());+ self.bytes.extend(value);+ }++ /// An additional value of the previous attribute (a `1setOf`): the name is empty.+ fn additional(&mut self, tag: u8, value: &str) {+ self.attribute(tag, "", value.as_bytes());+ }+}++/// A discovery request: the operation's required attributes, then the names to be answered with.+fn discovery_request(operation: u16) -> Vec<u8> {+ let mut r = Request::new(operation);+ r.group(GROUP_OPERATION);+ r.attribute(TAG_CHARSET, "attributes-charset", b"utf-8");+ r.attribute(TAG_LANGUAGE, "attributes-natural-language", b"en");+ r.attribute(TAG_URI, "printer-uri", b"ipp://localhost/");+ r.attribute(TAG_KEYWORD, "requested-attributes", b"printer-name");+ r.additional(TAG_KEYWORD, "printer-state");+ r.additional(TAG_KEYWORD, "printer-is-accepting-jobs");+ r.end();+ r.bytes+}++/// A Print-Job request for `queue`: the job's options as IPP attributes, the PDF as the document+/// data that follows the end of attributes.+fn print_job_request(queue: &str, pdf: &[u8], job: &Job) -> Vec<u8> {+ let mut r = Request::new(OPERATION_PRINT_JOB);+ r.group(GROUP_OPERATION);+ r.attribute(TAG_CHARSET, "attributes-charset", b"utf-8");+ r.attribute(TAG_LANGUAGE, "attributes-natural-language", b"en");+ r.attribute(TAG_URI, "printer-uri", format!("ipp://localhost/printers/{queue}").as_bytes());+ r.attribute(TAG_NAME, "job-name", truncate_utf8(&job.title, MAX_JOB_NAME).as_bytes());+ r.attribute(TAG_MIME, "document-format", b"application/pdf");+ r.group(GROUP_JOB);+ r.attribute(TAG_INTEGER, "copies", &job.copies.clamp(1, 999).to_be_bytes());+ r.attribute(TAG_BOOLEAN, "collate", &[u8::from(job.collate)]);+ if let Some(duplex) = job.duplex {+ r.attribute(+ TAG_KEYWORD,+ "sides",+ match duplex {+ Duplex::Off => "one-sided",+ Duplex::LongEdge => "two-sided-long-edge",+ Duplex::ShortEdge => "two-sided-short-edge",+ }+ .as_bytes(),+ );+ }+ if job.grayscale {+ r.attribute(TAG_KEYWORD, "print-color-mode", b"monochrome");+ }+ // PWG 5100.13 §6.2.5: `none` keeps every sheet at its size, `auto-fit` shrinks only+ // oversized ones — the same choices `lp`'s fit-to-page and print-scaling options make.+ r.attribute(+ TAG_KEYWORD,+ "print-scaling",+ match job.scaling {+ Scaling::Exact => "none",+ Scaling::ShrinkToFit => "auto-fit",+ }+ .as_bytes(),+ );+ r.end();+ r.bytes.extend(pdf);+ r.bytes+}++/// The string cut to at most `max` bytes, at a character boundary.+fn truncate_utf8(s: &str, max: usize) -> &str {+ if s.len() <= max {+ return s;+ }+ let mut end = max;+ while !s.is_char_boundary(end) {+ end -= 1;+ }+ // `end` is a checked boundary; nothing is the only name that cannot be cut.+ s.get(..end).unwrap_or_default()+}++/// The parsed parts of a reply: the IPP status, the printer names in its printer groups, and a+/// submission's job id.+struct Reply {+ status: u16,+ names: Vec<String>,+ job_id: Option<u32>,+}++/// A cursor over a reply: `None` at the end or past a length, never an over-read.+struct Reader<'a> {+ bytes: &'a [u8],+ pos: usize,+}++impl<'a> Reader<'a> {+ fn take(&mut self, len: usize) -> Option<&'a [u8]> {+ let end = self.pos.checked_add(len)?;+ let taken = self.bytes.get(self.pos..end)?;+ self.pos = end;+ Some(taken)+ }++ fn array<const N: usize>(&mut self) -> Option<[u8; N]> {+ <[u8; N]>::try_from(self.take(N)?).ok()+ }++ fn u16(&mut self) -> Option<u16> {+ self.array::<2>().map(u16::from_be_bytes)+ }+}++fn parse_reply(reply: &[u8]) -> Result<Reply, Error> {+ let malformed = || Error::Spool("the print server sent a malformed reply".into());+ let mut r = Reader { bytes: reply, pos: 0 };+ let _version = r.array::<2>().ok_or_else(malformed)?;+ let status = r.u16().ok_or_else(malformed)?;+ let _request_id = r.array::<4>().ok_or_else(malformed)?;+ let (mut names, mut job_id, mut group) = (Vec::new(), None, 0u8);+ loop {+ let Some(tag) = r.array::<1>().map(|t| t[0]) else { return Err(malformed()) };+ match tag {+ GROUP_END => break,+ GROUP_OPERATION | GROUP_JOB | GROUP_PRINTER | 0x05 => {+ group = tag;+ }+ // An attribute: name and value, each u16-length. An empty name is an additional+ // value of the previous one (the attributes read here are single-valued, so those+ // are ignored). Group tags not in the registry and collections (the attributes+ // requested here are never collections) are refused, not guessed.+ 0x10..=0x33 | 0x35..=0x4A => {+ let name_len = r.u16().ok_or_else(malformed)?;+ if usize::from(name_len) > MAX_NAME {+ return Err(malformed());+ }+ let name_bytes = r.take(usize::from(name_len)).ok_or_else(malformed)?;+ let name = std::str::from_utf8(name_bytes).map_err(|_| malformed())?;+ let value_len = r.u16().ok_or_else(malformed)?;+ let value = r.take(usize::from(value_len)).ok_or_else(malformed)?;+ match (group, name, tag) {+ (GROUP_PRINTER, "printer-name", TAG_NAME) => {+ if names.len() < MAX_PRINTERS+ && let Some(printer) = std::str::from_utf8(value).ok().filter(|n| !n.is_empty() && n.len() <= MAX_NAME)+ {+ names.push(printer.to_string());+ }+ }+ (GROUP_JOB, "job-id", TAG_INTEGER) => {+ if let Ok(id) = <[u8; 4]>::try_from(value) {+ job_id = Some(u32::from_be_bytes(id));+ }+ }+ _ => {}+ }+ }+ _ => return Err(malformed()),+ }+ }+ Ok(Reply { status, names, job_id })+}++/// Successful IPP status codes are `0x00xx` (RFC 8011 Appendix B).+fn status_ok(status: u16) -> bool {+ status < 0x0100+}++/// The IPP status code's name, for the error the user sees.+fn status_name(status: u16) -> Option<&'static str> {+ Some(match status {+ 0x0400 => "bad request",+ 0x0401 => "forbidden",+ 0x0402 => "not authenticated",+ 0x0403 => "not authorized",+ 0x0404 => "not possible",+ 0x0406 => "printer not found",+ 0x0408 => "request too large",+ 0x040A => "PDF is not a supported document format for this printer",+ 0x0412 => "the document could not be accessed",+ 0x0500 => "internal error",+ 0x0501 => "operation not supported",+ 0x0502 => "service unavailable",+ 0x0503 => "IPP version not supported",+ 0x0504 => "device error",+ 0x0505 => "temporary error",+ 0x0506 => "the printer is not accepting jobs",+ 0x0507 => "the printer is busy",+ _ => return None,+ })+}++fn refused(status: u16) -> Error {+ match status_name(status) {+ Some(name) => Error::Spool(format!("the print server refused the request ({name})")),+ None => Error::Spool(format!("the print server refused the request (IPP status 0x{status:04X})")),+ }+}@@ -1,6 +1,10 @@ //! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its //! queues, and the driverless destinations it can print to without one — and jobs are piped to-//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come+//! `lp` with the job options (copies, collation, duplex, colour). When those commands are+//! missing (a sandboxed install, storytold/photocraft#6081) or answer nothing, the server is+//! asked directly over IPP ([`ipp`]) and jobs go to it as Print-Job; when no CUPS server is+//! reachable, the job is handed to the desktop's print portal ([`portal`]), whose own dialog+//! restores the printer selection (storytold/pdfcraft#1905). On Windows the printers come //! from the spooler and jobs are drawn by the in-box `Windows.Data.Pdf` into the XPS print API //! with the options as a PrintTicket, all in this process (`windows.rs`): no PowerShell or other //! program is started, so printing works where scripts are locked down. Other platforms report@@ -8,6 +12,16 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// IPP/1.1 straight to the local CUPS server (discovery and Print-Job): the transport the print+/// path falls back to when `lpstat`/`lp` are missing (a sandboxed install), exposed for callers+/// that need the server directly.+#[cfg(all(unix, not(target_arch = "wasm32")))]+pub mod ipp;++/// The XDG desktop portal's print dialog: the print path when no CUPS server is reachable.+#[cfg(all(unix, not(target_arch = "wasm32")))]+pub mod portal;+ #[cfg(any(test, windows))] mod progress; #[cfg(any(test, windows))]@@ -361,17 +375,45 @@ fn lpstat_e_command() -> std::process::Command { lpstat_with(&["-e"]) } +/// Whether `name` can be started: an executable `name` on `PATH`, the way a `Command` resolves+/// one. Gated like its unix callers.+#[cfg(all(unix, not(target_arch = "wasm32")))]+fn spawnable(name: &str) -> bool {+ std::env::var_os("PATH").is_some_and(|path| spawnable_in(&path, name))+}++/// [`spawnable`] under a given search path, so tests can stand in for the environment.+#[cfg(all(unix, not(target_arch = "wasm32")))]+fn spawnable_in(path: &std::ffi::OsStr, name: &str) -> bool {+ std::env::split_paths(path).any(|dir| {+ std::fs::metadata(dir.join(name)).is_ok_and(|m| {+ use std::os::unix::fs::PermissionsExt;+ m.is_file() && m.permissions().mode() & 0o111 != 0+ })+ })+}+ /// The printers the system knows (empty when there are none or no spooler): its queues, then the-/// driverless destinations CUPS can print to without one.+/// driverless destinations CUPS can print to without one — from `lpstat` when it answers, else+/// over IPP ([`ipp::printers`]). pub fn printers() -> Vec<Printer> { #[cfg(all(unix, not(target_arch = "wasm32")))] {- let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) else {- return Vec::new();- };- // A spooler without `-e`, or one whose discovery times out, only loses the driverless names.- let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());- printers_parsed(&queues, available.as_deref())+ if let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) {+ // A spooler without `-e`, or one whose discovery times out, only loses the driverless names.+ let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());+ let parsed = printers_parsed(&queues, available.as_deref());+ if !parsed.is_empty() {+ return parsed;+ }+ }+ // `lpstat` missing (a sandboxed install) or answering nothing (a stopped scheduler) is+ // not "no printers" when the server itself is there: ask it directly. A server that+ // cannot be reached, or whose answer cannot be parsed, leaves the list empty as before.+ if ipp::cups_reachable() {+ return ipp::printers().unwrap_or_default();+ }+ Vec::new() } #[cfg(windows)] {@@ -449,6 +491,10 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, Error> { submit_with_progress(pdf, job, &mut |_, _| {}) } +/// The message when no transport can take the job at all: no spooler command, no CUPS server,+/// no print portal.+const PRINTING_UNAVAILABLE: &str = "printing to a printer isn't available on this platform yet; save the print-ready PDF instead";+ /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first /// sheet and after each sheet is successfully added to the native print job. Counts refer to /// source PDF sheets, regardless of copies or duplex. CUPS reports no progress.@@ -461,7 +507,18 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz #[cfg(all(unix, not(target_arch = "wasm32")))] { let _ = progress;- submit_via(std::process::Command::new("lp"), pdf, job)+ if spawnable("lp") {+ return submit_via(std::process::Command::new("lp"), pdf, job);+ }+ // No `lp`: the server itself takes the job as Print-Job (the driver's PPD options are+ // not sent on this path), else the portal's dialog asks the user for a printer.+ if ipp::cups_reachable() {+ return ipp::print_job(pdf, job);+ }+ if portal::print_dialog_available() {+ return portal::print(pdf, job);+ }+ Err(Error::Spool(PRINTING_UNAVAILABLE.into())) } #[cfg(windows)] {@@ -470,7 +527,7 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] { let _ = (pdf, job, progress);- Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into()))+ Err(Error::Spool(PRINTING_UNAVAILABLE.into())) } } @@ -535,10 +592,10 @@ pub(crate) fn job_message(stdout: &[u8]) -> String { s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string() } -/// [`command_preview`] for the Windows backend.-fn windows_preview(job: &Job) -> Vec<String> {+/// The job's dialog options as `key=value`: the lines both in-process previews print after the+/// transport name.+fn job_options(job: &Job) -> Vec<String> { vec![- "xps-print".into(), format!("printer={}", job.printer.as_deref().unwrap_or("(default)")), format!("copies={}", job.copies.clamp(1, 999)), format!("collate={}", job.collate),@@ -562,6 +619,47 @@ fn windows_preview(job: &Job) -> Vec<String> { ] } +/// [`command_preview`] for the Windows backend.+fn windows_preview(job: &Job) -> Vec<String> {+ std::iter::once("xps-print".to_string()).chain(job_options(job)).collect()+}++/// [`command_preview`] for the IPP transport: the Print-Job's attributes. The driver's PPD+/// options have no equivalent on this path, so they are not listed.+#[cfg(all(unix, not(target_arch = "wasm32")))]+fn ipp_preview(job: &Job) -> Vec<String> {+ std::iter::once("ipp".to_string()).chain(job_options(job)).collect()+}++/// [`command_preview`] for the print portal: the printer and the options are the user's to+/// choose in the portal's own dialog.+#[cfg(all(unix, not(target_arch = "wasm32")))]+fn portal_preview() -> Vec<String> {+ vec!["portal".into(), "printer=(dialog)".into()]+}++/// [`command_preview`] for the CUPS backend: the transport that would run — `lp` when the+/// spooler's command is there, else IPP to the reachable server, else the portal's dialog.+fn cups_preview(job: &Job) -> Result<Vec<String>, Error> {+ #[cfg(all(unix, not(target_arch = "wasm32")))]+ {+ if spawnable("lp") {+ return Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect());+ }+ if ipp::cups_reachable() {+ return Ok(ipp_preview(job));+ }+ if portal::print_dialog_available() {+ return Ok(portal_preview());+ }+ }+ #[cfg(not(all(unix, not(target_arch = "wasm32"))))]+ {+ let _ = job;+ }+ Err(Error::Spool(PRINTING_UNAVAILABLE.into()))+}+ /// The platform backend. No backend is available in the browser. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Backend {@@ -579,14 +677,15 @@ pub const fn backend() -> Option<Backend> { } } -/// A diagnostic command preview; creates no job file and starts no process.-/// On Windows, where no program is started, it describes the in-process job instead: `xps-print`,-/// then the printer and the PrintTicket's options as `key=value`. Submit with [`submit`],+/// A diagnostic command preview; creates no job file and starts no process. It names the+/// transport that would run: on CUPS `lp`, else IPP to the reachable server, else the portal's+/// dialog; on Windows, where no program is started, the in-process job instead (`xps-print`,+/// then the printer and the PrintTicket's options as `key=value`). Submit with [`submit`], /// rather than executing this preview. PDF bytes are supplied separately. pub fn command_preview(job: &Job) -> Result<Vec<String>, Error> { match backend() {- Some(Backend::Cups) => Ok(std::iter::once("lp".to_string()).chain(lp_args(job)).collect()),+ Some(Backend::Cups) => cups_preview(job), Some(Backend::WindowsPdf) => Ok(windows_preview(job)),- None => Err(Error::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())),+ None => Err(Error::Spool(PRINTING_UNAVAILABLE.into())), } }new file mode 100644@@ -0,0 +1,59 @@+//! The XDG desktop portal's print dialog (`org.freedesktop.portal.Print`): the print path for+//! installs where no CUPS server is reachable (a sandboxed one), where `lpstat`/`lp` are+//! missing too. The dialog is the portal backend's own, so the printer and the job's options+//! are the user's to choose there; this side hands over the print-ready PDF and reports it.++use super::{Error, Job};+use std::collections::HashMap;+use std::io::{Seek as _, Write as _};+use std::os::fd::AsFd as _;+use std::sync::OnceLock;+use std::time::Duration;++/// The desktop portal service, whichever backend provides it.+const PORTAL_SERVICE: &str = "org.freedesktop.portal.Desktop";+const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";+const PORTAL_INTERFACE: &str = "org.freedesktop.portal.Print";++/// A backend that has taken the call must not hold the worker forever.+const PORTAL_TIMEOUT: Duration = Duration::from_secs(30);++/// Whether the print dialog can be opened: the session bus connects. The probe is asked once;+/// a missing bus (a sandbox without one) is a normal `false`, not an error.+pub fn print_dialog_available() -> bool {+ static AVAILABLE: OnceLock<bool> = OnceLock::new();+ *AVAILABLE.get_or_init(|| session_bus().is_ok())+}++/// Hand the print-ready PDF to the portal's print dialog; the returned message is the receipt+/// of the handoff. The job goes through a private temporary file (0600, a random name, this+/// user's temporary folder) whose file descriptor the portal receives: the file is deleted as+/// soon as the portal has its own copy of the descriptor, and it reads the job through it when+/// the dialog closes. The printer and the job's options are chosen in the dialog, not here.+pub fn print(pdf: &[u8], job: &Job) -> Result<String, Error> {+ let connection = session_bus()?;+ let portal = zbus::blocking::Proxy::new(&connection, PORTAL_SERVICE, PORTAL_PATH, PORTAL_INTERFACE)+ .map_err(|e| Error::Spool(format!("the print portal could not be reached: {e}")))?;+ let mut file = tempfile::NamedTempFile::new().map_err(|e| Error::Spool(format!("the print job's file could not be created: {e}")))?;+ file.write_all(pdf)+ .and_then(|()| file.flush())+ // The portal reads through the descriptor from the start, and a descriptor shares the+ // offset with this file.+ .and_then(|()| file.rewind())+ .map_err(|e| Error::Spool(format!("the print job could not be written: {e}")))?;+ // `Print` takes the parent window (none here), the dialog title, the job's file descriptor+ // and the options; the dialog's own defaults apply. The reply is the request's object+ // path, on which the portal later reports the dialog's outcome.+ let options: HashMap<String, zbus::zvariant::Value> = HashMap::new();+ let _request: zbus::zvariant::OwnedObjectPath = portal+ .call("Print", &("", job.title.as_str(), zbus::zvariant::Fd::Borrowed(file.as_fd()), &options))+ .map_err(|e| Error::Spool(format!("the print dialog could not be opened: {e}")))?;+ Ok("the print dialog was opened; choose a printer there".into())+}++/// The user's session bus, which the portal lives on.+fn session_bus() -> Result<zbus::blocking::Connection, Error> {+ zbus::blocking::connection::Builder::session()+ .and_then(|builder| builder.method_timeout(PORTAL_TIMEOUT).build())+ .map_err(|e| Error::Spool(format!("the session bus could not be reached: {e}")))+}@@ -1,6 +1,15 @@ use super::*; use crate as spool; +#[cfg(all(unix, not(target_arch = "wasm32")))]+mod ipp;++#[cfg(all(unix, not(target_arch = "wasm32")))]+mod portal;++#[cfg(all(unix, not(target_arch = "wasm32")))]+mod route;+ mod windows_native; #[test]@@ -250,11 +259,20 @@ fn printer_option_defaults_and_job_arguments() { #[test] fn command_previews_do_not_require_pdf_files() { let job = Job { printer: Some("Office (east)".into()), copies: 2, ..Job::default() };- let preview = command_preview(&job).unwrap(); if backend() == Some(Backend::Cups) {+ // Without `lp` this host answers from the fallback transports, or refuses with none at+ // all; tests::route covers those forms under a controlled PATH.+ #[cfg(all(unix, not(target_arch = "wasm32")))]+ {+ if !spool::spawnable("lp") {+ return;+ }+ }+ let preview = command_preview(&job).unwrap(); assert_eq!(preview[0], "lp"); assert_eq!(preview[1..], lp_args(&job)); } else {+ let preview = command_preview(&job).unwrap(); assert_eq!( preview, ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"]new file mode 100644@@ -0,0 +1,343 @@+//! The IPP client against a fake CUPS server (a unix socket or a loopback port): discovery,+//! Print-Job, and the malformed replies it must refuse without a panic.++use super::*;+use crate as spool;+use crate::ipp::{self, Server};+use std::collections::VecDeque;+use std::io::{Read, Write};+use std::sync::Mutex;+use std::sync::mpsc::{self, Receiver};+use std::time::Duration;++const TIMEOUT: Duration = Duration::from_secs(2);++/// A fake CUPS server: each connection carries one request; the next canned reply goes back+/// (nothing, when the list is out — the client then sees a hang-up). Every request is recorded+/// for the assertions.+struct FakeCups {+ server: Server,+ requests: Receiver<Vec<u8>>,+}++impl FakeCups {+ fn on_unix(replies: Vec<Vec<u8>>) -> Self {+ let dir = tempfile::tempdir().unwrap();+ let path = dir.path().join("cups.sock");+ let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();+ // The socket must outlive this function; the folder is left for the process to reap.+ std::mem::forget(dir);+ let requests = listen_unix(listener, replies);+ FakeCups { server: Server::Unix(path), requests }+ }++ fn on_tcp(replies: Vec<Vec<u8>>) -> Self {+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();+ let server = Server::Tcp(listener.local_addr().unwrap());+ let requests = listen_tcp(listener, replies);+ FakeCups { server, requests }+ }++ fn request(&self) -> Vec<u8> {+ self.requests.recv_timeout(Duration::from_secs(5)).expect("the fake server took the request")+ }++ /// The server as the one-endpoint list the client functions take.+ fn endpoint(&self) -> &[Server] {+ std::slice::from_ref(&self.server)+ }+}++fn listen_unix(listener: std::os::unix::net::UnixListener, replies: Vec<Vec<u8>>) -> Receiver<Vec<u8>> {+ let (tx, rx) = mpsc::channel();+ let replies = Mutex::new(VecDeque::from(replies));+ std::thread::spawn(move || {+ for stream in listener.incoming().flatten() {+ serve(stream, &replies, &tx);+ }+ });+ rx+}++fn listen_tcp(listener: std::net::TcpListener, replies: Vec<Vec<u8>>) -> Receiver<Vec<u8>> {+ let (tx, rx) = mpsc::channel();+ let replies = Mutex::new(VecDeque::from(replies));+ std::thread::spawn(move || {+ for stream in listener.incoming().flatten() {+ serve(stream, &replies, &tx);+ }+ });+ rx+}++fn serve(mut stream: impl Read + Write, replies: &Mutex<VecDeque<Vec<u8>>>, requests: &mpsc::Sender<Vec<u8>>) {+ let Some(request) = read_request(&mut stream) else { return };+ let _ = requests.send(request);+ // A list out of replies is a server that hangs up before answering.+ if let Some(reply) = replies.lock().unwrap().pop_front() {+ let _ = stream.write_all(&reply);+ let _ = stream.flush();+ }+}++/// One HTTP request: the headers (bounded), then the `Content-Length` body it announces.+fn read_request(stream: &mut impl Read) -> Option<Vec<u8>> {+ let mut buf = Vec::new();+ let mut byte = [0u8; 1];+ loop {+ match stream.read(&mut byte) {+ Ok(0) | Err(_) => return None,+ Ok(_) => buf.push(byte[0]),+ }+ if buf.ends_with(b"\r\n\r\n") {+ break;+ }+ if buf.len() > 1 << 16 {+ return None;+ }+ }+ let head = String::from_utf8_lossy(&buf);+ let len: usize = head.lines().find_map(|l| l.strip_prefix("Content-Length:")).and_then(|v| v.trim().parse().ok())?;+ let head_len = buf.len();+ buf.resize(head_len + len, 0);+ stream.read_exact(&mut buf[head_len..]).ok()?;+ Some(buf)+}++/// An HTTP reply carrying `body`, as CUPS would send it.+fn http_reply(body: &[u8]) -> Vec<u8> {+ let mut out = format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes();+ out.extend(body);+ out+}++/// An IPP reply body: `status`, then the attribute groups as raw bytes, then end of attributes.+fn ipp_reply(status: u16, groups: &[&[u8]]) -> Vec<u8> {+ let mut out = vec![1, 1];+ out.extend(status.to_be_bytes());+ out.extend(1u32.to_be_bytes());+ for &group in groups {+ out.extend(group);+ }+ out.push(0x03);+ out+}++fn group(tag: u8) -> Vec<u8> {+ vec![tag]+}++fn printer_name(name: &str) -> Vec<u8> {+ attr(0x42, "printer-name", name.as_bytes())+}++/// One attribute, framed the way the client frames them, so `has` finds it in a request.+fn attr(tag: u8, name: &str, value: &[u8]) -> Vec<u8> {+ let mut out = vec![tag];+ out.extend((name.len() as u16).to_be_bytes());+ out.extend(name.as_bytes());+ out.extend((value.len() as u16).to_be_bytes());+ out.extend(value);+ out+}++fn has(bytes: &[u8], needle: &[u8]) -> bool {+ !needle.is_empty() && bytes.windows(needle.len()).any(|w| w == needle)+}++/// The IPP part of a recorded request: everything after the HTTP head.+fn ipp_body(request: &[u8]) -> &[u8] {+ let at = request.windows(4).position(|w| w == b"\r\n\r\n").expect("an HTTP request has a head");+ &request[at + 4..]+}++fn entry(name: &str, default: bool) -> spool::Printer {+ spool::Printer { name: name.into(), default, two_sided: None }+}++#[test]+fn discovery_finds_queues_and_the_default_over_a_unix_socket() {+ let fake = FakeCups::on_unix(vec![+ http_reply(&ipp_reply(+ 0x0000,+ &[+ &group(0x01),+ &printer_name("Office_Laser"),+ &group(0x04),+ &printer_name("Office_Laser"),+ &group(0x04),+ &printer_name("Label_Writer"),+ &group(0x04),+ &printer_name("Office_Laser"),+ ],+ )),+ http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office_Laser")])),+ ]);+ let printers = ipp::printers_at(fake.endpoint()).unwrap();+ assert_eq!(printers, [entry("Office_Laser", true), entry("Label_Writer", false)], "each queue once, the default marked");+ let taken = fake.request();+ let queues = ipp_body(&taken);+ assert_eq!(&queues[..4], &[1, 1, 0x40, 0x02], "IPP 1.1, CUPS-Get-Printers");+ assert!(has(queues, b"ipp://localhost/"));+ assert!(has(queues, &attr(0x44, "requested-attributes", b"printer-name")));+ assert!(has(queues, b"printer-is-accepting-jobs"), "only the attributes the dialog needs are asked for");+ let taken = fake.request();+ let default = ipp_body(&taken);+ assert_eq!(&default[..4], &[1, 1, 0x40, 0x01], "CUPS-Get-Default");+}++#[test]+fn a_print_job_carries_the_pdf_and_the_options() {+ let pdf: Vec<u8> = b"%PDF-1.7 fake print job\n".iter().copied().cycle().take(1 << 20).collect();+ let job = spool::Job {+ printer: Some("Office".into()),+ copies: 2,+ collate: false,+ duplex: Some(spool::Duplex::LongEdge),+ grayscale: true,+ scaling: spool::Scaling::ShrinkToFit,+ title: "memo.pdf".into(),+ ..Job::default()+ };+ let fake = FakeCups::on_tcp(vec![http_reply(&ipp_reply(+ 0x0000,+ &[&group(0x01), &group(0x02), &attr(0x21, "job-id", &12u32.to_be_bytes()), &attr(0x23, "job-state", &[3u8])],+ ))]);+ let message = ipp::print_job_at(fake.endpoint(), &pdf, &job).unwrap();+ assert_eq!(message, "request id is Office-12", "the same message lp prints");+ let taken = fake.request();+ let request = ipp_body(&taken);+ assert_eq!(&request[..4], &[1, 1, 0x00, 0x02], "IPP 1.1, Print-Job");+ assert!(has(request, b"ipp://localhost/printers/Office"));+ let mut copies = group(0x02);+ copies.extend(attr(0x21, "copies", &2u32.to_be_bytes()));+ assert!(has(request, &copies), "copies are a job attribute");+ assert!(has(request, &attr(0x22, "collate", &[0])));+ assert!(has(request, &attr(0x44, "sides", b"two-sided-long-edge")));+ assert!(has(request, &attr(0x44, "print-color-mode", b"monochrome")));+ assert!(has(request, &attr(0x44, "print-scaling", b"auto-fit")));+ assert!(has(request, &attr(0x49, "document-format", b"application/pdf")));+ assert!(has(request, &attr(0x42, "job-name", b"memo.pdf")));+ assert!(request.ends_with(&pdf), "the whole job is the document data");+}++#[test]+fn the_default_printer_is_used_when_the_job_has_none() {+ let fake = FakeCups::on_tcp(vec![+ http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office")])),+ http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x02), &attr(0x21, "job-id", &7u32.to_be_bytes())])),+ ]);+ let message = ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &Job::default()).unwrap();+ assert_eq!(message, "request id is Office-7");+ let default = fake.request();+ assert_eq!(&ipp_body(&default)[..4], &[1, 1, 0x40, 0x01], "the default comes from CUPS-Get-Default");+ let taken = fake.request();+ assert!(has(ipp_body(&taken), b"ipp://localhost/printers/Office"), "the resolved default is the target");+}++#[test]+fn a_default_outside_the_queue_list_still_gets_its_marker() {+ let fake = FakeCups::on_unix(vec![+ http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Office_Laser")])),+ http_reply(&ipp_reply(0x0000, &[&group(0x01), &group(0x04), &printer_name("Label_Writer")])),+ ]);+ let printers = ipp::printers_at(fake.endpoint()).unwrap();+ assert_eq!(+ printers,+ [entry("Office_Laser", false), entry("Label_Writer", true)],+ "the default joins the list, so the dialog has a selection to restore"+ );+}++#[test]+fn no_default_printer_is_an_error_when_the_job_names_none() {+ let fake = FakeCups::on_unix(vec![http_reply(&ipp_reply(0x0000, &[&group(0x01)]))]);+ let err = ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &Job::default()).unwrap_err();+ assert!(err.to_string().contains("no default printer"), "{err}");+}++#[test]+fn a_printer_name_that_cannot_be_a_uri_path_is_refused() {+ let fake = FakeCups::on_tcp(vec![]);+ let mut long = "x".repeat(257);+ long.push('y');+ for name in ["Office (east)", "../../admin", "", "a b", "Brother/HL", "Копир", long.as_str()] {+ let job = spool::Job { printer: Some(name.into()), ..Job::default() };+ assert_eq!(+ ipp::print_job_at(fake.endpoint(), b"pdf", &job),+ Err(spool::Error::Spool(format!("not a printer name: {name}"))),+ "{name:?} cannot go in the printer URI's path"+ );+ }+}++#[test]+fn an_ipp_refusal_is_an_error_that_names_the_problem() {+ let fake = FakeCups::on_tcp(vec![http_reply(&ipp_reply(0x0506, &[&group(0x01)]))]);+ let job = spool::Job { printer: Some("Office".into()), ..Job::default() };+ assert_eq!(+ ipp::print_job_at(fake.endpoint(), b"%PDF-1.7\n", &job),+ Err(spool::Error::Spool("the print server refused the request (the printer is not accepting jobs)".into()))+ );+}++#[test]+fn malformed_replies_are_errors_never_a_panic() {+ let bodies: Vec<(&str, Vec<u8>)> = vec![+ // Shorter than an IPP header.+ ("shorter than an IPP header", vec![1, 1, 0]),+ // A reply that ends inside an attribute: the name is announced, the value never comes.+ ("cut inside an attribute", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0x20, b'p']),+ // A value tag the registry does not know.+ ("unknown value tag", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0xFF, 0x00, 0x00, 0x00, 0x00]),+ // A collection, which the attributes asked for never are.+ ("collection", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x34, 0x00, 0x00, 0x00, 0x00]),+ // A group tag that is no delimiter in the registry.+ ("reserved delimiter", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x06]),+ // A length within the cap that runs past the reply, and one past the cap.+ ("length past the reply", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0xFE, b'p']),+ ("length past the cap", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0xFF, 0xFF, b'p']),+ // No end of attributes before the body runs out.+ ("no end of attributes", vec![1, 1, 0, 0, 0, 0, 0, 1, 0x04, 0x42, 0x00, 0x01, b'x']),+ ];+ for (why, body) in bodies {+ let fake = FakeCups::on_unix(vec![http_reply(&body)]);+ let result = ipp::printers_at(fake.endpoint());+ assert!(result.is_err(), "{why}: the reply must be an error, not a panic or a success");+ }+}++#[test]+fn http_replies_that_cannot_carry_an_answer_are_refused() {+ let replies: Vec<(&str, Vec<u8>)> = vec![+ ("requires authentication", b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n".to_vec()),+ ("demands a TLS upgrade", b"HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\n\r\n".to_vec()),+ ("chunked", b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n25\r\nthe body is chunked\r\n0\r\n\r\n".to_vec()),+ ("no Content-Length", b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\n\r\n".to_vec()),+ ("Content-Length past the cap", b"HTTP/1.1 200 OK\r\nContent-Length: 99999999\r\n\r\n".to_vec()),+ ("unreadable status line", b"not HTTP at all\r\n\r\n".to_vec()),+ ];+ for (why, reply) in replies {+ let fake = FakeCups::on_unix(vec![reply]);+ let result = ipp::post(&[fake.server], "/", b"ignored", TIMEOUT, true);+ assert!(result.is_err(), "{why}: the reply must be an error, not a panic or a success");+ }+ // A server that takes the request and hangs up.+ let fake = FakeCups::on_unix(vec![]);+ assert!(ipp::post(&[fake.server], "/", b"ignored", TIMEOUT, true).is_err(), "a hang-up is an error");+}++#[test]+fn a_server_that_answers_is_reachable_and_silence_is_not() {+ let fake = FakeCups::on_unix(vec![]);+ assert!(ipp::reachable(&fake.server), "a unix socket that accepts connections is reachable");+ let fake = FakeCups::on_tcp(vec![]);+ assert!(ipp::reachable(&fake.server), "a loopback port that accepts connections is reachable");+ let absent = Server::Unix("/nonexistent/craft-print-test.sock".into());+ assert!(!ipp::reachable(&absent), "a socket nothing listens on is not reachable");+ let addr = {+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();+ listener.local_addr().unwrap()+ };+ assert!(!ipp::reachable(&Server::Tcp(addr)), "a port nothing listens on is not reachable");+}new file mode 100644@@ -0,0 +1,34 @@+//! The portal path's tests: without a session bus, the dialog is a normal `false` and the job+//! a clean `Err` — checked in a child process, because the bus address is process state and+//! changing the environment here is forbidden.++use super::*;+use crate as spool;++/// Set on the child process when a test runs this binary as a print caller without a session+/// bus: the child's `DBUS_SESSION_BUS_ADDRESS` then names a socket that does not exist.+const NO_BUS: &str = "CRAFT_STAND_IN_NO_BUS";++#[test]+fn stand_in_without_a_session_bus() {+ if std::env::var_os(NO_BUS).is_none() {+ return;+ }+ // The bus address names a socket that does not exist: the session bus is simply missing.+ assert!(!spool::portal::print_dialog_available(), "a missing bus is false, not an error or a panic");+ let err = spool::portal::print(b"%PDF-1.7\n", &Job::default()).unwrap_err();+ assert!(err.to_string().contains("session bus"), "{err}");+}++#[test]+fn a_missing_session_bus_is_a_normal_false_and_a_clean_error() {+ let dead = std::env::temp_dir().join(format!("craft-print-no-session-bus-{}", std::process::id()));+ let _ = std::fs::remove_file(&dead);+ let mut child = std::process::Command::new(std::env::current_exe().unwrap())+ .args(["--exact", "tests::portal::stand_in_without_a_session_bus", "--nocapture"])+ .env(NO_BUS, "1")+ .env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", dead.display()))+ .spawn()+ .unwrap();+ assert!(child.wait().unwrap().success(), "without a session bus, the portal path fails cleanly");+}new file mode 100644@@ -0,0 +1,173 @@+//! The routing between the transports: `lpstat`/`lp` when the CUPS command-line tools are+//! there, then IPP to the reachable server, then the portal. Every environment-dependent route+//! runs in a child process under a controlled `PATH` and a session bus pointed at a socket that+//! does not exist (the environment is process state, and changing it here is forbidden), so no+//! test can reach this host's spooler, CUPS server or portal. A reachable CUPS server cannot be+//! faked — `cups_reachable` probes fixed paths — so the routes that follow it skip when one is+//! really reachable; ipp.rs's fake-server tests are the oracle for the IPP transport itself.++use super::*;+use crate as spool;++/// Set on the child process that re-runs a routing test under the stand-in commands.+const ROUTE_CHILD: &str = "CRAFT_ROUTE_CHILD";++/// A folder of stand-in spooler commands: `name` → the script body under a `#!/bin/sh` shebang.+fn stand_in_dir(commands: &[(&str, &str)]) -> (tempfile::TempDir, std::path::PathBuf) {+ let dir = tempfile::TempDir::new().unwrap();+ use std::os::unix::fs::PermissionsExt;+ for (name, body) in commands {+ let path = dir.path().join(name);+ std::fs::write(&path, format!("#!/bin/sh\n{body}")).unwrap();+ let mut permissions = std::fs::metadata(&path).unwrap().permissions();+ permissions.set_mode(0o755);+ std::fs::set_permissions(&path, permissions).unwrap();+ }+ let bin = dir.path().to_path_buf();+ (dir, bin)+}++/// Re-runs `name` as a child with `bin` as its `PATH` and the session bus pointed at a socket+/// that does not exist, so routing cannot open this host's print dialog.+fn route_child(name: &str, bin: &std::path::Path) -> std::process::Output {+ let dead_bus = std::env::temp_dir().join(format!("craft-print-no-session-bus-{}", std::process::id()));+ let _ = std::fs::remove_file(&dead_bus);+ std::process::Command::new(std::env::current_exe().unwrap())+ .args(["--exact", name, "--nocapture"])+ .env("PATH", bin)+ .env(ROUTE_CHILD, "1")+ .env("DBUS_SESSION_BUS_ADDRESS", format!("unix:path={}", dead_bus.display()))+ .output()+ .unwrap()+}++/// Runs `scenario` when this binary was re-run as a routing child; otherwise runs it as a child+/// under the stand-in environment and asserts that it passed.+fn routed(name: &str, bin: &std::path::Path, scenario: impl FnOnce()) {+ if std::env::var_os(ROUTE_CHILD).is_some() {+ scenario();+ return;+ }+ let out = route_child(name, bin);+ assert!(out.status.success(), "the routing child failed:\n{}\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));+}++#[test]+fn spawnable_needs_an_executable_file_on_the_search_path() {+ use std::os::unix::fs::PermissionsExt;+ let (dir, bin) = stand_in_dir(&[("lp", ":")]);+ assert!(spool::spawnable_in(bin.as_os_str(), "lp"), "the stand-in is spawnable");+ std::fs::set_permissions(dir.path().join("lp"), std::fs::Permissions::from_mode(0o644)).unwrap();+ assert!(!spool::spawnable_in(bin.as_os_str(), "lp"), "a file without the execute bits is not");+ std::fs::create_dir(dir.path().join("lpstat")).unwrap();+ assert!(!spool::spawnable_in(bin.as_os_str(), "lpstat"), "a directory is not");+ assert!(!spool::spawnable_in(bin.as_os_str(), "lpoptions"), "a name the path lacks is not");+ assert!(!spool::spawnable_in("".as_ref(), "lp"), "an empty search path spawns nothing");+}++#[test]+fn lpstat_that_answers_wins_over_the_fallbacks() {+ let (_dir, bin) = stand_in_dir(&[(+ "lpstat",+ r#"if [ "$1" = "-e" ]; then echo "Office_Laser"; else echo "printer Office_Laser is idle."; echo "system default destination: Office_Laser"; fi"#,+ )]);+ routed("tests::route::lpstat_that_answers_wins_over_the_fallbacks", &bin, || {+ let printers = spool::printers();+ assert_eq!(printers.len(), 1, "{printers:?}");+ assert_eq!(printers[0], spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None });+ });+}++#[test]+fn lpstat_that_answers_nothing_falls_back_to_the_server() {+ if spool::ipp::cups_reachable() {+ // A real server is reachable: the fallback would ask it, and its queues are not this+ // test's business (ipp.rs tests the server answers). Skip rather than depend on the host.+ return;+ }+ let (_dir, bin) = stand_in_dir(&[(+ "lpstat",+ r#"if [ "$1" = "-e" ]; then exit 0; fi; echo "no system default destination"; echo "lpstat: Scheduler is not running." >&2; exit 1"#,+ )]);+ routed("tests::route::lpstat_that_answers_nothing_falls_back_to_the_server", &bin, || {+ assert!(spool::printers().is_empty(), "a stopped scheduler with no reachable server has no printers");+ });+}++#[test]+fn without_lpstat_the_list_stays_empty_without_a_server() {+ if spool::ipp::cups_reachable() {+ return;+ }+ let (_dir, bin) = stand_in_dir(&[]);+ routed("tests::route::without_lpstat_the_list_stays_empty_without_a_server", &bin, || {+ assert!(spool::printers().is_empty(), "a sandbox without the tools and without a server has no printers");+ });+}++#[test]+fn print_jobs_go_to_lp_when_it_is_there() {+ let (_dir, bin) = stand_in_dir(&[("lp", "PATH=/usr/bin:/bin; export PATH; cat >/dev/null; echo \"request id is Route-1\"")]);+ routed("tests::route::print_jobs_go_to_lp_when_it_is_there", &bin, || {+ let sent = spool::submit_with_progress(b"%PDF-1.7 synthetic routing job\n", &Job::default(), &mut |_, _| {});+ assert_eq!(sent, Ok("request id is Route-1".to_string()), "the stand-in lp spooled the job");+ });+}++#[test]+fn without_a_transport_the_job_is_refused() {+ if spool::ipp::cups_reachable() {+ return;+ }+ let (_dir, bin) = stand_in_dir(&[]);+ routed("tests::route::without_a_transport_the_job_is_refused", &bin, || {+ let err = spool::submit_with_progress(b"%PDF-1.7\n", &Job::default(), &mut |_, _| {}).unwrap_err();+ assert_eq!(err, Error::Spool(spool::PRINTING_UNAVAILABLE.into()), "no spooler, no server, no portal");+ });+}++#[test]+fn the_ipp_preview_describes_the_print_job() {+ let job = Job {+ printer: Some("Office_Laser".into()),+ copies: 2,+ duplex: Some(Duplex::LongEdge),+ grayscale: true,+ scaling: Scaling::ShrinkToFit,+ ..Job::default()+ };+ assert_eq!(+ spool::ipp_preview(&job),+ ["ipp", "printer=Office_Laser", "copies=2", "collate=true", "duplex=two-sided-long-edge", "scaling=shrink-to-fit", "color=monochrome"]+ );+ assert_eq!(+ spool::ipp_preview(&Job::default()),+ ["ipp", "printer=(default)", "copies=1", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"]+ );+}++#[test]+fn the_portal_preview_points_at_its_dialog() {+ assert_eq!(spool::portal_preview(), ["portal", "printer=(dialog)"]);+}++#[test]+fn the_preview_names_lp_when_it_would_run() {+ let (_dir, bin) = stand_in_dir(&[("lp", ":")]);+ routed("tests::route::the_preview_names_lp_when_it_would_run", &bin, || {+ let preview = spool::command_preview(&Job::default()).unwrap();+ assert_eq!(preview[0], "lp", "{preview:?}");+ assert_eq!(preview[1..], lp_args(&Job::default()));+ });+}++#[test]+fn the_preview_refuses_when_no_transport_exists() {+ if spool::ipp::cups_reachable() {+ return;+ }+ let (_dir, bin) = stand_in_dir(&[]);+ routed("tests::route::the_preview_refuses_when_no_transport_exists", &bin, || {+ assert_eq!(spool::command_preview(&Job::default()), Err(Error::Spool(spool::PRINTING_UNAVAILABLE.into())));+ });+}