collective

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

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -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",+]diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml--- a/crates/print/Cargo.toml+++ b/crates/print/Cargo.toml@@ -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"diff --git a/crates/print/src/ipp.rs b/crates/print/src/ipp.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/ipp.rs@@ -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})")),+    }+}diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs--- a/crates/print/src/lib.rs+++ b/crates/print/src/lib.rs@@ -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())),     } }diff --git a/crates/print/src/portal.rs b/crates/print/src/portal.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/portal.rs@@ -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}")))+}diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs--- a/crates/print/src/tests.rs+++ b/crates/print/src/tests.rs@@ -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"]diff --git a/crates/print/src/tests/ipp.rs b/crates/print/src/tests/ipp.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/tests/ipp.rs@@ -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");+}diff --git a/crates/print/src/tests/portal.rs b/crates/print/src/tests/portal.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/tests/portal.rs@@ -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");+}diff --git a/crates/print/src/tests/route.rs b/crates/print/src/tests/route.rsnew file mode 100644--- /dev/null+++ b/crates/print/src/tests/route.rs@@ -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())));+    });+}