Skip to main content

ipp_printer_app/
server.rs

1//! Axum HTTP server: IPP over POST `/ipp/print/:name`.
2
3use std::io::{Cursor, Read};
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::attributes::{
8    self, build_get_jobs_response, build_job_attrs_response, get_printer_attributes,
9    print_job_accepted, validate_job,
10};
11use crate::device::DeviceBackend;
12use crate::job::{JobId, JobRegistry, JobState};
13use crate::printer::{PrinterRecord, PrinterRegistry};
14use crate::raster::JobOutcome;
15use crate::state::PersistedState;
16use axum::body::Bytes;
17use axum::extract::Form;
18use axum::extract::{Path, State};
19use axum::http::{header, StatusCode};
20use axum::response::IntoResponse;
21use axum::routing::{get, post};
22use axum::Router;
23use ipp::model::Operation;
24use ipp::model::StatusCode as IppStatus;
25use ipp::parser::IppParser;
26use ipp::prelude::*;
27use ipp::reader::IppReader;
28use num_traits::FromPrimitive;
29
30/// Context passed to a print-job worker so it can observe cancellation and
31/// report progress without re-querying the registry.
32#[derive(Clone)]
33#[allow(missing_docs)]
34pub struct JobContext {
35    pub id: JobId,
36    pub printer_name: String,
37    pub cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
38    /// The `document-format` the client sent (RFC 8011), e.g.
39    /// `image/pwg-raster` or `image/jpeg`. Defaults to
40    /// `application/octet-stream` when the client omits it. The print
41    /// callback branches on this to pick a decoder.
42    pub document_format: String,
43    /// `print-quality` (RFC 8011: 3 draft, 4 normal, 5 high), when the client
44    /// sent it. CUPS derives it from the `cupsPrintQuality` PPD choice, so on
45    /// a driverless queue this is the one per-job quality knob that reaches a
46    /// driver — the generated PPD maps quality only to `HWResolution`, which
47    /// collapses to a single value on a fixed-resolution printer, leaving the
48    /// raster identical for all three.
49    pub print_quality: Option<u8>,
50}
51
52impl JobContext {
53    /// True once the client has canceled this job. A print callback that loops
54    /// (e.g. waiting on hardware) should poll this and bail promptly.
55    pub fn is_canceled(&self) -> bool {
56        self.cancel_flag.load(std::sync::atomic::Ordering::Acquire)
57    }
58}
59
60/// Callback that prints one document to the device, returning a [`JobOutcome`]
61/// that tells the framework what to do with the job.
62///
63/// It receives the payload by reference because the framework may call it more
64/// than once: returning [`JobOutcome::DeviceUnavailable`] makes the framework
65/// hold the job and re-invoke this callback (with backoff) until it prints or
66/// the client cancels — so classify a transient device condition as
67/// `DeviceUnavailable`, not `Failed`. Do any cheap reachability check (opening
68/// the device) *first* so a held retry is cheap. The callback should also bail
69/// early if [`JobContext::is_canceled`] becomes true.
70/// Boxed, owned future a [`PrintJobFn`] returns. The payload is handed over as
71/// an `Arc<[u8]>` (not a borrow) so the future is `'static` and can run on a
72/// spawned task that outlives the call.
73pub type PrintJobFuture =
74    std::pin::Pin<Box<dyn std::future::Future<Output = crate::raster::JobOutcome> + Send>>;
75
76/// The print-job callback: given a job context, the document payload, and a
77/// copy count, returns a future resolving to the [`crate::raster::JobOutcome`].
78/// The framework drives spooling/retry around it.
79pub type PrintJobFn = Arc<dyn Fn(JobContext, Arc<[u8]>, u32) -> PrintJobFuture + Send + Sync>;
80
81/// Boxed, owned future a [`MediaChangeFn`] returns.
82pub type MediaChangeFuture =
83    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send>>;
84
85/// Called when an operator sets the loaded media from the web UI, **before**
86/// the change is written to the config.
87///
88/// Returning `Err(reason)` refuses the change: the config is left untouched and
89/// the page shows the reason. That matters because the consumer is the only
90/// party that knows whether the device will accept it — a printer whose media
91/// is described by an RFID tag, for instance, must not have that tag's geometry
92/// contradicted by a hand-entered size.
93///
94/// Given the printer's logical name and the requested media.
95pub type MediaChangeFn =
96    Arc<dyn Fn(String, crate::device::ReadyMedia) -> MediaChangeFuture + Send + Sync>;
97
98/// Server configuration. Construct in your `main`, hand to [`Server::run`].
99#[allow(missing_docs)]
100pub struct ServerOptions {
101    pub host: String,
102    pub port: u16,
103    pub printers: PrinterRegistry,
104    pub device_backend: Arc<dyn DeviceBackend>,
105    pub print_job: PrintJobFn,
106    /// Optional hook invoked when an operator sets the loaded media from the
107    /// web UI. `None` accepts every change without consulting the device.
108    pub media_change: Option<MediaChangeFn>,
109    pub state_path: std::path::PathBuf,
110    /// When `true` (and the `mdns` feature is on), [`Server::run`] starts the
111    /// DNS-SD advertiser itself, immediately, from the registry as it stands
112    /// at bind time. Set `false` if the caller needs to advertise later —
113    /// e.g. after assigning each [`crate::printer::PrinterRecord::uuid`] from
114    /// an external source (a CUPS queue's `printer-uuid`) so the advertised
115    /// `UUID=` matches a local queue and cups-browsed dedupes it. The caller
116    /// is then responsible for calling [`crate::mdns::Advertiser::register_all`]
117    /// and holding the handle.
118    pub advertise_mdns: bool,
119}
120
121/// Axum-shared state. Constructed internally by [`Server::router`]; exposed
122/// only so external middleware can read the printer registry.
123#[derive(Clone)]
124#[allow(missing_docs)]
125pub struct AppState {
126    pub host: String,
127    pub port: u16,
128    pub printers: PrinterRegistry,
129    pub print_job: PrintJobFn,
130    pub media_change: Option<MediaChangeFn>,
131    pub state_path: std::path::PathBuf,
132    pub jobs: JobRegistry,
133    pub device_backend: Arc<dyn DeviceBackend>,
134}
135
136/// Entry point — `Server::run(opts).await` starts the listener.
137pub struct Server;
138
139impl Server {
140    /// Build the axum router with the configured state attached. Returned
141    /// router can be served via [`Server::run`] or by hand.
142    pub fn router(opts: ServerOptions) -> Router {
143        let state = AppState {
144            host: opts.host.clone(),
145            port: opts.port,
146            printers: opts.printers.clone(),
147            print_job: opts.print_job,
148            media_change: opts.media_change,
149            state_path: opts.state_path,
150            jobs: JobRegistry::new(),
151            device_backend: opts.device_backend,
152        };
153
154        Router::new()
155            .route("/", get(index_handler))
156            .route("/icon.png", get(icon_handler))
157            .route(
158                "/media/{name}",
159                get(media_form_handler).post(media_apply_handler),
160            )
161            .route("/ipp/print/{name}", post(ipp_handler))
162            .route("/ipp/print/{name}/", post(ipp_handler))
163            .with_state(state)
164    }
165
166    /// Bind to `host:port`, spawn the background status poller (and the mDNS
167    /// advertiser if the `mdns` feature is enabled), and run the axum
168    /// listener until it errors.
169    pub async fn run(opts: ServerOptions) -> std::io::Result<()> {
170        let addr = format!("{}:{}", opts.host, opts.port);
171        let listener = tokio::net::TcpListener::bind(&addr).await?;
172        log::info!("ipp-printer-app listening on http://{addr}");
173
174        // mDNS advertising for IPP-Everywhere auto-discovery. Skipped when the
175        // caller opts to advertise itself later (see ServerOptions::advertise_mdns).
176        // Created before the poller so the poller can withdraw/republish each
177        // printer's advert as its device goes offline / comes back online.
178        #[cfg(feature = "mdns")]
179        let advertiser: Option<Arc<dyn crate::status::AdvertiserControl>> = if opts.advertise_mdns {
180            match crate::mdns::Advertiser::register_all(&opts.printers, opts.port) {
181                Ok(adv) => Some(Arc::new(adv)),
182                Err(e) => {
183                    log::warn!("mdns: failed to register printers: {e}");
184                    None
185                }
186            }
187        } else {
188            None
189        };
190        #[cfg(not(feature = "mdns"))]
191        let advertiser: Option<Arc<dyn crate::status::AdvertiserControl>> = None;
192
193        // Background status poller — refreshes printer-state-reasons and drives
194        // the advertiser on offline/online transitions.
195        let _status = crate::status::spawn(
196            opts.device_backend.clone(),
197            opts.printers.clone(),
198            advertiser.clone(),
199            opts.state_path.clone(),
200        );
201        // Hold the advertiser for the server's lifetime (Drop withdraws all).
202        let _advertiser = advertiser;
203
204        axum::serve(listener, Self::router(opts)).await
205    }
206
207    /// Load printers from disk, discover devices, merge into registry.
208    pub async fn bootstrap_printers(
209        registry: &PrinterRegistry,
210        backend: &dyn DeviceBackend,
211        state_path: &std::path::Path,
212        make_config: impl Fn(&str, &str, &str, &str, &str) -> Option<crate::printer::PrinterConfig>,
213    ) {
214        let mut records: Vec<PrinterRecord> = PersistedState::load(state_path)
215            .printers
216            .into_iter()
217            .map(PrinterRecord::new)
218            .collect();
219
220        for d in backend.list().await {
221            let Some(driver) = backend.driver_for_device(&d.device_id, &d.uri) else {
222                continue;
223            };
224            let name = printer_name_from_uri(&d.uri, &d.info);
225            if records.iter().any(|r| r.config.device_uri == d.uri) {
226                continue;
227            }
228            let Some(cfg) = make_config(&name, &d.info, &driver, &d.uri, &d.device_id) else {
229                continue;
230            };
231            log::info!("auto-add printer {name} -> {}", d.uri);
232            records.push(PrinterRecord::new(cfg));
233        }
234
235        *registry.write() = records;
236        Self::persist(registry, state_path);
237    }
238
239    /// Snapshot the registry to `state_path` as JSON. Called automatically
240    /// at the end of every print job; expose for callers that want to
241    /// persist after manual registry edits.
242    pub fn persist(registry: &PrinterRegistry, state_path: &std::path::Path) {
243        let configs: Vec<_> = registry.read().iter().map(|r| r.config.clone()).collect();
244        let _ = PersistedState { printers: configs }.save(state_path);
245    }
246}
247
248/// Logical queue name proposed during bootstrap. Lowercases and maps every
249/// non-alphanumeric run to a single `_`, mirroring CUPS's own DNS-SD
250/// queue-name sanitiser (`cups_queue_name`) so that — given a case-insensitive
251/// CUPS name lookup — our persistent queue matches the on-demand temp queue
252/// CUPS would derive from the (spaced) DNS-SD instance name, and no duplicate
253/// is created. The `make_config` callback receives this as its `name` arg and
254/// may override by returning a [`PrinterConfig`] with a different `name`.
255fn printer_name_from_uri(uri: &str, info: &str) -> String {
256    let source = if info.is_empty() { uri } else { info };
257    let slug: String = source
258        .chars()
259        .map(|c| {
260            if c.is_ascii_alphanumeric() {
261                c.to_ascii_lowercase()
262            } else {
263                '_'
264            }
265        })
266        .collect();
267    let trimmed = slug.trim_matches('_');
268    let collapsed: String = trimmed
269        .split('_')
270        .filter(|s| !s.is_empty())
271        .collect::<Vec<_>>()
272        .join("_");
273    if collapsed.is_empty() {
274        "printer".to_string()
275    } else {
276        collapsed
277    }
278}
279
280/// Escape the few characters that would let a config value break out of the
281/// HTML we build by hand.
282fn esc(s: &str) -> String {
283    s.replace('&', "&amp;")
284        .replace('<', "&lt;")
285        .replace('>', "&gt;")
286        .replace('"', "&quot;")
287}
288
289/// Operator-entered media geometry, in whole millimetres — the unit printed on
290/// the roll, rather than the hundredths the protocol uses.
291#[derive(serde::Deserialize)]
292struct MediaForm {
293    width_mm: u32,
294    height_mm: u32,
295}
296
297/// The media a printer currently reports, in millimetres, preferring live
298/// device data over the configured default.
299fn current_media_mm(record: &PrinterRecord) -> (u32, u32) {
300    let hmm = match &record.ready_media {
301        Some(rm) => rm.size_hmm,
302        None => record
303            .config
304            .media_sizes
305            .first()
306            .copied()
307            .unwrap_or([4000, 3000]),
308    };
309    ((hmm[0] / 100) as u32, (hmm[1] / 100) as u32)
310}
311
312fn media_page(record: &PrinterRecord, message: Option<&str>, is_error: bool) -> String {
313    let cfg = &record.config;
314    let (w, h) = current_media_mm(record);
315    let bounds = if cfg.media_size_min.iter().all(|&v| v > 0) {
316        format!(
317            "<p>Accepts {}×{}mm to {}×{}mm.</p>",
318            cfg.media_size_min[0] / 100,
319            cfg.media_size_min[1] / 100,
320            cfg.media_size_max[0] / 100,
321            cfg.media_size_max[1] / 100,
322        )
323    } else {
324        String::new()
325    };
326    let banner = match message {
327        Some(m) if is_error => format!("<p style=\"color:#b00\"><b>{}</b></p>", esc(m)),
328        Some(m) => format!("<p style=\"color:#070\">{}</p>", esc(m)),
329        None => String::new(),
330    };
331    format!(
332        "<!DOCTYPE html><html><head><title>Media — {name}</title></head><body>\
333         <h1>Loaded media</h1><h2>{label}</h2>{banner}{bounds}\
334         <form method=\"post\">\
335         <label>Width <input name=\"width_mm\" type=\"number\" min=\"1\" value=\"{w}\"></label> mm<br>\
336         <label>Length <input name=\"height_mm\" type=\"number\" min=\"1\" value=\"{h}\"></label> mm<br>\
337         <button type=\"submit\">Set</button></form>\
338         <p><a href=\"/\">Back</a></p></body></html>",
339        name = esc(&cfg.name),
340        label = esc(cfg.display_label()),
341    )
342}
343
344async fn media_form_handler(
345    State(state): State<AppState>,
346    Path(name): Path<String>,
347) -> impl IntoResponse {
348    let printers = state.printers.read();
349    match printers.iter().find(|p| p.config.name == name) {
350        Some(record) => (
351            StatusCode::OK,
352            axum::response::Html(media_page(record, None, false)),
353        ),
354        None => (
355            StatusCode::NOT_FOUND,
356            axum::response::Html("<p>No such printer.</p>".to_string()),
357        ),
358    }
359}
360
361async fn media_apply_handler(
362    State(state): State<AppState>,
363    Path(name): Path<String>,
364    Form(form): Form<MediaForm>,
365) -> impl IntoResponse {
366    // Snapshot rather than holding the lock: the hook is async and may talk to
367    // the device, which can take hundreds of milliseconds.
368    let Some(record) = state
369        .printers
370        .read()
371        .iter()
372        .find(|p| p.config.name == name)
373        .cloned()
374    else {
375        return (
376            StatusCode::NOT_FOUND,
377            axum::response::Html("<p>No such printer.</p>".to_string()),
378        );
379    };
380
381    let size_hmm = [form.width_mm as i32 * 100, form.height_mm as i32 * 100];
382    let cfg = &record.config;
383    if cfg.media_size_min.iter().all(|&v| v > 0)
384        && (size_hmm[0] < cfg.media_size_min[0]
385            || size_hmm[1] < cfg.media_size_min[1]
386            || size_hmm[0] > cfg.media_size_max[0]
387            || size_hmm[1] > cfg.media_size_max[1])
388    {
389        let msg = format!(
390            "{}×{}mm is outside the supported range.",
391            form.width_mm, form.height_mm
392        );
393        return (
394            StatusCode::BAD_REQUEST,
395            axum::response::Html(media_page(&record, Some(&msg), true)),
396        );
397    }
398
399    let media = crate::device::ReadyMedia {
400        name: format!(
401            "om_{}x{}mm_{}x{}mm",
402            form.width_mm, form.height_mm, form.width_mm, form.height_mm
403        ),
404        size_hmm,
405        media_type: "labels".to_string(),
406    };
407
408    // Ask the consumer first — it owns the device and may refuse. Nothing is
409    // written until it agrees.
410    if let Some(hook) = &state.media_change {
411        if let Err(reason) = hook(name.clone(), media.clone()).await {
412            log::info!("{name}: media change refused: {reason}");
413            return (
414                StatusCode::CONFLICT,
415                axum::response::Html(media_page(&record, Some(&reason), true)),
416            );
417        }
418    }
419
420    let updated = {
421        let mut printers = state.printers.write();
422        let Some(rec) = printers.iter_mut().find(|p| p.config.name == name) else {
423            return (
424                StatusCode::NOT_FOUND,
425                axum::response::Html("<p>No such printer.</p>".to_string()),
426            );
427        };
428        rec.set_ready_media(media.clone());
429        rec.clone()
430    };
431    Server::persist(&state.printers, &state.state_path);
432    log::info!(
433        "{name}: media set to {}x{}mm",
434        form.width_mm,
435        form.height_mm
436    );
437
438    let msg = format!(
439        "Loaded media set to {}×{}mm.",
440        form.width_mm, form.height_mm
441    );
442    (
443        StatusCode::OK,
444        axum::response::Html(media_page(&updated, Some(&msg), false)),
445    )
446}
447
448async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
449    let printers = state.printers.read();
450    let mut html = String::from(
451        "<!DOCTYPE html><html><head><title>ipp-printer-app</title></head><body>\
452         <h1>ipp-printer-app</h1><ul>",
453    );
454    for p in printers.iter() {
455        let uri = p.config.printer_uri(&state.host, state.port);
456        html.push_str(&format!(
457            "<li><b>{}</b> (<code>{}</code>) — <code>{uri}</code> — device <code>{}</code> — <a href=\"/media/{}\">media</a></li>",
458            p.config.display_label(),
459            p.config.name,
460            p.config.device_uri,
461            p.config.name
462        ));
463    }
464    html.push_str(&format!(
465        "</ul><p>Register with CUPS: <code>lpadmin -p NAME -E -v \
466         ipp://{}:{}/ipp/print/NAME -m everywhere</code></p></body></html>",
467        if state.host.is_empty() || state.host == "0.0.0.0" || state.host == "::" {
468            "localhost"
469        } else {
470            &state.host
471        },
472        state.port,
473    ));
474    (
475        StatusCode::OK,
476        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
477        html,
478    )
479}
480
481/// Serve the printer icon advertised in `printer-icons`. A 1×1 transparent
482/// PNG keeps the resource valid without shipping artwork; consumers that want
483/// a real icon can layer their own route ahead of this one.
484async fn icon_handler() -> impl IntoResponse {
485    const ICON_PNG: &[u8] = &[
486        0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
487        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
488        0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x62, 0x00,
489        0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
490        0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
491    ];
492    (
493        StatusCode::OK,
494        [(header::CONTENT_TYPE, "image/png")],
495        ICON_PNG,
496    )
497}
498
499async fn ipp_handler(
500    State(state): State<AppState>,
501    Path(name): Path<String>,
502    body: Bytes,
503) -> impl IntoResponse {
504    match handle_ipp(&state, &name, &body).await {
505        Ok(bytes) => (
506            StatusCode::OK,
507            [(header::CONTENT_TYPE, "application/ipp")],
508            bytes,
509        ),
510        Err((status, msg)) => (
511            status,
512            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
513            msg.into_bytes(),
514        ),
515    }
516}
517
518async fn handle_ipp(
519    state: &AppState,
520    name: &str,
521    body: &[u8],
522) -> Result<Vec<u8>, (StatusCode, String)> {
523    let mut req = IppParser::new(IppReader::new(Cursor::new(body.to_vec())))
524        .parse()
525        .map_err(|e| (StatusCode::BAD_REQUEST, format!("IPP parse error: {e}")))?;
526
527    let version = req.header().version;
528    let request_id = req.header().request_id;
529    let op_code = req.header().operation_or_status;
530
531    // RFC 8011 §4.1.8: reject IPP versions outside the 1.x / 2.x families.
532    let major = version.0 >> 8;
533    if major != 1 && major != 2 {
534        let resp = IppRequestResponse::new_response(
535            IppVersion::v1_1(),
536            IppStatus::ServerErrorVersionNotSupported,
537            request_id,
538        )
539        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
540        return Ok(resp.to_bytes().to_vec());
541    }
542
543    // RFC 8011 §4.1.4: the operation-attributes group must begin with
544    // `attributes-charset` followed by `attributes-natural-language`, in that
545    // order. The `ipp` crate parses attributes into a hash map (losing wire
546    // order), so we check the first two names against the raw request bytes.
547    // Wrong order / missing → `client-error-bad-request`.
548    if !operation_attributes_well_ordered(body) {
549        let resp =
550            IppRequestResponse::new_response(version, IppStatus::ClientErrorBadRequest, request_id)
551                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
552        return Ok(resp.to_bytes().to_vec());
553    }
554
555    // RFC 8011 §4.1.1: a request-id of 0 is invalid and must be rejected with
556    // `client-error-bad-request`. We answer in-band (HTTP 200 + IPP status) so
557    // conformant clients see the IPP error rather than a transport failure.
558    if request_id == 0 {
559        let resp =
560            IppRequestResponse::new_response(version, IppStatus::ClientErrorBadRequest, request_id)
561                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
562        return Ok(resp.to_bytes().to_vec());
563    }
564
565    // RFC 8011 §4.2: an operation must target the printer (or a job) via a
566    // `printer-uri` (or `job-uri`) operation attribute. We still route by the
567    // request path, but a request carrying neither is malformed.
568    if !has_operation_attr(&req, "printer-uri") && !has_operation_attr(&req, "job-uri") {
569        let resp =
570            IppRequestResponse::new_response(version, IppStatus::ClientErrorBadRequest, request_id)
571                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
572        return Ok(resp.to_bytes().to_vec());
573    }
574
575    let record = {
576        let guard = state.printers.read();
577        guard
578            .iter()
579            .find(|p| p.config.name == name)
580            .cloned()
581            .ok_or((StatusCode::NOT_FOUND, format!("printer not found: {name}")))?
582    };
583
584    // PWG 5100.14 required operations that the `ipp` crate's `Operation` enum
585    // doesn't model. Dispatch them by raw code before the enum conversion.
586    const OP_CANCEL_MY_JOBS: u16 = 0x0039;
587    const OP_CLOSE_JOB: u16 = 0x003b;
588    const OP_IDENTIFY_PRINTER: u16 = 0x003c;
589    match op_code {
590        OP_CLOSE_JOB => {
591            // We finalize jobs eagerly on Send-Document, so Close-Job is an
592            // acknowledgement — succeed if the job exists.
593            let status = match extract_job_id(&req).and_then(|id| state.jobs.get(id)) {
594                Some(_) => IppStatus::SuccessfulOk,
595                None => IppStatus::ClientErrorNotFound,
596            };
597            let resp = IppRequestResponse::new_response(version, status, request_id)
598                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
599            return Ok(resp.to_bytes().to_vec());
600        }
601        OP_CANCEL_MY_JOBS => {
602            for j in state.jobs.jobs_for_printer(name) {
603                state.jobs.cancel(j.id);
604            }
605            let resp =
606                IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
607                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
608            return Ok(resp.to_bytes().to_vec());
609        }
610        OP_IDENTIFY_PRINTER => {
611            let actions = extract_identify_actions(&req);
612            state
613                .device_backend
614                .identify(&record.config, &actions)
615                .await;
616            let resp =
617                IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
618                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
619            return Ok(resp.to_bytes().to_vec());
620        }
621        _ => {}
622    }
623
624    let op = Operation::from_u16(op_code)
625        .ok_or((StatusCode::BAD_REQUEST, "unknown IPP operation".into()))?;
626
627    let resp = match op {
628        Operation::GetPrinterAttributes => {
629            let requested = extract_requested_attributes(&req);
630            get_printer_attributes(
631                version,
632                request_id,
633                &record,
634                &state.host,
635                state.port,
636                requested.as_ref(),
637            )
638            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
639        }
640        Operation::ValidateJob => {
641            validate_job(version, request_id, &record, &state.host, state.port)
642                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
643        }
644        Operation::PrintJob => {
645            let copies = extract_copies(&req);
646            let quality = extract_print_quality(&req);
647            let format = extract_document_format(&req);
648            let mut payload = Vec::new();
649            req.payload_mut()
650                .read_to_end(&mut payload)
651                .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
652
653            let job = state.jobs.create(name.to_string(), requesting_user(&req));
654            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
655            let accepted = print_job_accepted(version, request_id, &job, &printer_uri_str)
656                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
657
658            spawn_print_worker(
659                state,
660                name.to_string(),
661                job,
662                payload,
663                copies,
664                format,
665                quality,
666            );
667            accepted
668        }
669        Operation::CreateJob => {
670            // Document-less job creation; the document arrives via Send-Document.
671            let job = state.jobs.create(name.to_string(), requesting_user(&req));
672            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
673            print_job_accepted(version, request_id, &job, &printer_uri_str)
674                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
675        }
676        Operation::SendDocument => {
677            // Attach a document to an existing (Create-Job) job and, on the
678            // last document, hand it to the print worker. We don't support
679            // multi-document jobs (multiple-document-jobs-supported = false),
680            // so each Send-Document carries the whole job.
681            // RFC 8011 §3.3.1: Send-Document requires the `last-document`
682            // boolean operation attribute.
683            if !has_operation_attr(&req, "last-document") {
684                let resp = IppRequestResponse::new_response(
685                    version,
686                    IppStatus::ClientErrorBadRequest,
687                    request_id,
688                )
689                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
690                return Ok(resp.to_bytes().to_vec());
691            }
692            let job_id = extract_job_id(&req).ok_or((
693                StatusCode::BAD_REQUEST,
694                "Send-Document missing job-id".to_string(),
695            ))?;
696            let job = state
697                .jobs
698                .get(job_id)
699                .ok_or((StatusCode::NOT_FOUND, format!("job not found: {job_id}")))?;
700            let copies = extract_copies(&req);
701            let quality = extract_print_quality(&req);
702            let format = extract_document_format(&req);
703            let mut payload = Vec::new();
704            req.payload_mut()
705                .read_to_end(&mut payload)
706                .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
707
708            if !payload.is_empty() {
709                spawn_print_worker(
710                    state,
711                    name.to_string(),
712                    job.clone(),
713                    payload,
714                    copies,
715                    format,
716                    quality,
717                );
718            }
719            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
720            build_job_attrs_response(version, request_id, &job, &printer_uri_str, None)
721                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
722        }
723        Operation::GetJobs => {
724            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
725            let mut jobs = state.jobs.jobs_for_printer(name);
726            // RFC 8011 §3.2.6.1: `my-jobs=true` scopes the listing to the
727            // requesting user's own jobs.
728            if my_jobs_flag(&req) {
729                let user = requesting_user(&req);
730                jobs.retain(|j| j.owner == user);
731            }
732            // When the client omits `requested-attributes`, Get-Jobs returns
733            // only `job-uri` and `job-id`.
734            let requested = extract_requested_attributes(&req);
735            let default_set = ["job-uri".to_string(), "job-id".to_string()]
736                .into_iter()
737                .collect();
738            let filter = effective_filter(requested.as_ref(), &default_set);
739            build_get_jobs_response(version, request_id, &jobs, &printer_uri_str, filter)
740                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
741        }
742        Operation::GetJobAttributes => {
743            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
744            let job_id = extract_job_id(&req).ok_or((
745                StatusCode::BAD_REQUEST,
746                "Get-Job-Attributes missing job-id".to_string(),
747            ))?;
748            let job = state
749                .jobs
750                .get(job_id)
751                .ok_or((StatusCode::NOT_FOUND, format!("job not found: {job_id}")))?;
752            // Get-Job-Attributes default is "all" — filter only when the
753            // client supplies a concrete `requested-attributes` set.
754            let requested = extract_requested_attributes(&req);
755            let all = std::collections::BTreeSet::new();
756            let filter = effective_filter(requested.as_ref(), &all);
757            build_job_attrs_response(version, request_id, &job, &printer_uri_str, filter)
758                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
759        }
760        Operation::CancelJob => {
761            let job_id = extract_job_id(&req).ok_or((
762                StatusCode::BAD_REQUEST,
763                "Cancel-Job missing job-id".to_string(),
764            ))?;
765            let status = match state.jobs.cancel(job_id) {
766                None => IppStatus::ClientErrorNotFound,
767                Some(JobState::Canceled) => IppStatus::SuccessfulOk,
768                Some(_) => IppStatus::ClientErrorNotPossible,
769            };
770            IppRequestResponse::new_response(version, status, request_id)
771                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
772        }
773        _ => {
774            return Err((
775                StatusCode::BAD_REQUEST,
776                format!("unsupported IPP operation: {op:?}"),
777            ));
778        }
779    };
780
781    Ok(resp.to_bytes().to_vec())
782}
783
784/// RFC 8011 §4.1.4: the operation-attributes group must start with
785/// `attributes-charset` then `attributes-natural-language`, in that order.
786/// Parses the first two attribute *names* directly from the raw IPP request
787/// (header is 8 bytes; the operation group is the first delimiter group) and
788/// checks them. Returns `false` on a short/malformed buffer.
789fn operation_attributes_well_ordered(body: &[u8]) -> bool {
790    // header: version(2) operation(2) request-id(4), then delimiter tag.
791    if body.len() <= 8 || body[8] != ipp::model::DelimiterTag::OperationAttributes as u8 {
792        return false;
793    }
794    let mut i = 9;
795    let mut names: Vec<&[u8]> = Vec::new();
796    while i < body.len() && names.len() < 2 {
797        let tag = body[i];
798        if tag <= 0x0f {
799            break; // next delimiter tag → end of operation group
800        }
801        i += 1;
802        if i + 2 > body.len() {
803            return false;
804        }
805        let name_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
806        i += 2;
807        if i + name_len > body.len() {
808            return false;
809        }
810        // name_len == 0 marks an additional value of the previous attribute.
811        if name_len > 0 {
812            names.push(&body[i..i + name_len]);
813        }
814        i += name_len;
815        if i + 2 > body.len() {
816            return false;
817        }
818        let value_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
819        i += 2 + value_len;
820    }
821    names.first() == Some(&b"attributes-charset".as_slice())
822        && names.get(1) == Some(&b"attributes-natural-language".as_slice())
823}
824
825/// True if an attribute named `name` is present in the operation-attributes
826/// group of `req`.
827fn has_operation_attr(req: &IppRequestResponse, name: &str) -> bool {
828    req.attributes()
829        .groups()
830        .iter()
831        .filter(|g| g.tag() == ipp::model::DelimiterTag::OperationAttributes)
832        .any(|g| g.attributes().keys().any(|k| k.as_str() == name))
833}
834
835fn extract_job_id(req: &IppRequestResponse) -> Option<JobId> {
836    for group in req.attributes().groups() {
837        for attr in group.attributes().values() {
838            if attr.name().as_str() == "job-id" {
839                if let IppValue::Integer(n) = attr.value() {
840                    return Some((*n) as JobId);
841                }
842            }
843            if attr.name().as_str() == "job-uri" {
844                if let IppValue::Uri(s) = attr.value() {
845                    return s.as_str().rsplit('/').next().and_then(|s| s.parse().ok());
846                }
847            }
848        }
849    }
850    None
851}
852
853/// Read the `print-quality` job attribute (enum 3/4/5), if present.
854fn extract_print_quality(req: &IppRequestResponse) -> Option<u8> {
855    for group in req.attributes().groups() {
856        for attr in group.attributes().values() {
857            if attr.name().as_str() == "print-quality" {
858                if let IppValue::Enum(n) = attr.value() {
859                    if (3..=5).contains(n) {
860                        return Some(*n as u8);
861                    }
862                }
863            }
864        }
865    }
866    None
867}
868
869fn extract_copies(req: &IppRequestResponse) -> u32 {
870    for group in req.attributes().groups() {
871        for attr in group.attributes().values() {
872            if attr.name().as_str() == "copies" {
873                if let IppValue::Integer(n) = attr.value() {
874                    return (*n).max(1) as u32;
875                }
876            }
877        }
878    }
879    0
880}
881
882/// Read the `document-format` operation attribute. Defaults to
883/// `application/octet-stream` (the IPP default) when the client omits it.
884fn extract_document_format(req: &IppRequestResponse) -> String {
885    for group in req.attributes().groups() {
886        for attr in group.attributes().values() {
887            if attr.name().as_str() == "document-format" {
888                if let IppValue::MimeMediaType(s) = attr.value() {
889                    return s.as_str().to_string();
890                }
891            }
892        }
893    }
894    "application/octet-stream".to_string()
895}
896
897/// Collect the client's `requested-attributes` values (RFC 8011 §4.2.5).
898/// Returns `None` when the attribute is absent (caller treats that as "all").
899fn extract_requested_attributes(
900    req: &IppRequestResponse,
901) -> Option<std::collections::BTreeSet<String>> {
902    for group in req.attributes().groups() {
903        for attr in group.attributes().values() {
904            if attr.name().as_str() == "requested-attributes" {
905                let mut set = std::collections::BTreeSet::new();
906                for v in attr.value().into_iter() {
907                    if let IppValue::Keyword(k) = v {
908                        set.insert(k.as_str().to_string());
909                    }
910                }
911                return Some(set);
912            }
913        }
914    }
915    None
916}
917
918/// Resolve the effective attribute filter for a job query. `requested` is the
919/// client's `requested-attributes` (if any); `default` is the operation's
920/// default set (empty = "all"). Returns `None` to mean "return everything".
921fn effective_filter<'a>(
922    requested: Option<&'a std::collections::BTreeSet<String>>,
923    default: &'a std::collections::BTreeSet<String>,
924) -> Option<&'a std::collections::BTreeSet<String>> {
925    match requested {
926        None => (!default.is_empty()).then_some(default),
927        Some(set) if set.is_empty() || set.contains("all") => None,
928        Some(set) => Some(set),
929    }
930}
931
932/// Read `requesting-user-name` from the operation attributes, defaulting to
933/// `anonymous` when the client omits it.
934fn requesting_user(req: &IppRequestResponse) -> String {
935    for group in req.attributes().groups() {
936        for attr in group.attributes().values() {
937            if attr.name().as_str() == "requesting-user-name" {
938                if let IppValue::NameWithoutLanguage(s) = attr.value() {
939                    return s.as_str().to_string();
940                }
941            }
942        }
943    }
944    "anonymous".to_string()
945}
946
947/// Read the `my-jobs` boolean operation attribute (default `false`).
948fn my_jobs_flag(req: &IppRequestResponse) -> bool {
949    for group in req.attributes().groups() {
950        for attr in group.attributes().values() {
951            if attr.name().as_str() == "my-jobs" {
952                if let IppValue::Boolean(b) = attr.value() {
953                    return *b;
954                }
955            }
956        }
957    }
958    false
959}
960
961/// Collect `identify-actions` keywords from an Identify-Printer request.
962fn extract_identify_actions(req: &IppRequestResponse) -> Vec<String> {
963    for group in req.attributes().groups() {
964        for attr in group.attributes().values() {
965            if attr.name().as_str() == "identify-actions" {
966                return attr
967                    .value()
968                    .into_iter()
969                    .filter_map(|v| match v {
970                        IppValue::Keyword(k) => Some(k.as_str().to_string()),
971                        _ => None,
972                    })
973                    .collect();
974            }
975        }
976    }
977    Vec::new()
978}
979
980/// Spawn the background worker that runs a print job to completion, updating
981/// printer/job state and persisting at the end. Shared by Print-Job and
982/// Send-Document.
983fn spawn_print_worker(
984    state: &AppState,
985    printer_name: String,
986    job: crate::job::JobRecord,
987    payload: Vec<u8>,
988    copies: u32,
989    document_format: String,
990    print_quality: Option<u8>,
991) {
992    let state_clone = state.clone();
993    let name_owned = printer_name;
994    let job_for_worker = job;
995    let payload: Arc<[u8]> = payload.into();
996    // Runs on the ambient tokio runtime (spawn_print_worker is called from the
997    // async IPP handler). The job future awaits the device transport directly.
998    tokio::spawn(async move {
999        // The printer stays `Processing` for the whole life of the job —
1000        // including while held waiting for the device. That keeps the status
1001        // poller (which only touches Idle/Stopped printers) off the device so
1002        // it can't contend with our retries.
1003        {
1004            let mut guard = state_clone.printers.write();
1005            if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
1006                attributes::set_printer_processing(p);
1007            }
1008        }
1009        state_clone
1010            .jobs
1011            .set_state(job_for_worker.id, JobState::Processing);
1012        let ctx = JobContext {
1013            id: job_for_worker.id,
1014            printer_name: name_owned.clone(),
1015            cancel_flag: job_for_worker.cancel_flag.clone(),
1016            document_format,
1017            print_quality,
1018        };
1019
1020        // Retry/hold loop. A `DeviceUnavailable` outcome holds the job
1021        // (`processing-stopped`) and retries with capped backoff until the
1022        // device prints it, the job is canceled, or it hits a hard failure —
1023        // the printer-application equivalent of holding a job through a jam.
1024        const BACKOFF_MAX: Duration = Duration::from_secs(30);
1025        // Initial retry backoff; doubles up to BACKOFF_MAX. Override for tests
1026        // / tuning with IPP_PRINTER_APP_RETRY_MS.
1027        let backoff_start = std::env::var("IPP_PRINTER_APP_RETRY_MS")
1028            .ok()
1029            .and_then(|s| s.parse().ok())
1030            .map(Duration::from_millis)
1031            .unwrap_or(Duration::from_secs(2));
1032        let mut backoff = backoff_start.min(BACKOFF_MAX);
1033        let mut held = false;
1034        loop {
1035            if ctx.is_canceled() {
1036                break;
1037            }
1038            match (state_clone.print_job)(ctx.clone(), payload.clone(), copies).await {
1039                JobOutcome::Completed => {
1040                    set_printer_reasons(
1041                        &state_clone,
1042                        &name_owned,
1043                        crate::flags::PrinterReason::empty(),
1044                    );
1045                    set_printer_idle_named(&state_clone, &name_owned);
1046                    // Don't clobber a Cancel that landed mid-print.
1047                    if !ctx.is_canceled() {
1048                        state_clone
1049                            .jobs
1050                            .set_state(job_for_worker.id, JobState::Completed);
1051                    }
1052                    break;
1053                }
1054                JobOutcome::Failed(f) => {
1055                    log::error!(
1056                        "print job {} failed: {} (reasons={:?})",
1057                        job_for_worker.id,
1058                        f.message,
1059                        f.printer_reasons
1060                    );
1061                    set_printer_reasons(&state_clone, &name_owned, f.printer_reasons);
1062                    set_printer_idle_named(&state_clone, &name_owned);
1063                    state_clone
1064                        .jobs
1065                        .set_failure(job_for_worker.id, f.printer_reasons, f.message);
1066                    break;
1067                }
1068                JobOutcome::DeviceUnavailable { reasons } => {
1069                    if !held {
1070                        held = true;
1071                        log::info!(
1072                            "print job {} held: device unavailable (reasons={:?}); will retry until it prints or is canceled",
1073                            job_for_worker.id, reasons
1074                        );
1075                    }
1076                    // Surface the condition but keep printer-state Processing.
1077                    set_printer_reasons(&state_clone, &name_owned, reasons);
1078                    state_clone
1079                        .jobs
1080                        .set_state(job_for_worker.id, JobState::ProcessingStopped);
1081                    if sleep_cancelable(&ctx.cancel_flag, backoff).await {
1082                        break; // canceled during the wait
1083                    }
1084                    backoff = (backoff * 2).min(BACKOFF_MAX);
1085                }
1086            }
1087        }
1088
1089        if ctx.is_canceled() {
1090            // Reflect the cancel and clear any held condition.
1091            set_printer_reasons(
1092                &state_clone,
1093                &name_owned,
1094                crate::flags::PrinterReason::empty(),
1095            );
1096            set_printer_idle_named(&state_clone, &name_owned);
1097            state_clone.jobs.cancel(job_for_worker.id);
1098        }
1099        Server::persist(&state_clone.printers, &state_clone.state_path);
1100    });
1101}
1102
1103/// Set `printer-state-reasons` for the named printer.
1104fn set_printer_reasons(state: &AppState, name: &str, reasons: crate::flags::PrinterReason) {
1105    let mut guard = state.printers.write();
1106    if let Some(p) = guard.iter_mut().find(|p| p.config.name == name) {
1107        p.reasons = reasons;
1108    }
1109}
1110
1111/// Return the named printer to `idle`.
1112fn set_printer_idle_named(state: &AppState, name: &str) {
1113    let mut guard = state.printers.write();
1114    if let Some(p) = guard.iter_mut().find(|p| p.config.name == name) {
1115        attributes::set_printer_idle(p);
1116    }
1117}
1118
1119/// Sleep up to `dur`, waking early (and returning `true`) if the cancel flag is
1120/// set. Polls in short slices so a Cancel-Job is honored promptly.
1121async fn sleep_cancelable(
1122    cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
1123    dur: Duration,
1124) -> bool {
1125    const SLICE: Duration = Duration::from_millis(250);
1126    let mut left = dur;
1127    while left > Duration::ZERO {
1128        if cancel.load(std::sync::atomic::Ordering::Acquire) {
1129            return true;
1130        }
1131        let nap = left.min(SLICE);
1132        tokio::time::sleep(nap).await;
1133        left -= nap;
1134    }
1135    cancel.load(std::sync::atomic::Ordering::Acquire)
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140
1141    /// An empty Print-Job request to hang job attributes on.
1142    fn job_request() -> IppRequestResponse {
1143        IppRequestResponse::new(
1144            ipp::model::IppVersion::v2_0(),
1145            ipp::model::Operation::PrintJob,
1146            Some("ipp://localhost/ipp/print".parse().unwrap()),
1147        )
1148        .expect("build request")
1149    }
1150
1151    /// CUPS converts the `cupsPrintQuality` PPD choice into a `print-quality`
1152    /// enum on the outgoing job (`_cupsConvertOptions`), so this is the one
1153    /// per-job quality signal that survives to a driverless printer — the
1154    /// generated PPD maps all three qualities onto the same `HWResolution` for
1155    /// a fixed-resolution device, leaving the raster byte-identical.
1156    #[test]
1157    fn print_quality_is_read_from_the_job_group() {
1158        for (sent, want) in [(3i32, Some(3u8)), (4, Some(4)), (5, Some(5))] {
1159            let mut req = job_request();
1160            req.attributes_mut().add(
1161                DelimiterTag::JobAttributes,
1162                IppAttribute::new("print-quality".try_into().unwrap(), IppValue::Enum(sent)),
1163            );
1164            assert_eq!(extract_print_quality(&req), want, "quality {sent}");
1165        }
1166    }
1167
1168    /// Absent, or outside the enum, means "no opinion" — the driver keeps its
1169    /// configured darkness rather than inventing one.
1170    #[test]
1171    fn absent_or_invalid_print_quality_is_none() {
1172        assert_eq!(extract_print_quality(&job_request()), None);
1173
1174        let mut odd = job_request();
1175        odd.attributes_mut().add(
1176            DelimiterTag::JobAttributes,
1177            IppAttribute::new("print-quality".try_into().unwrap(), IppValue::Enum(9)),
1178        );
1179        assert_eq!(extract_print_quality(&odd), None);
1180    }
1181    use super::*;
1182    use crate::device::DeviceBackend;
1183    use crate::flags::PrinterReason;
1184    use crate::job::JobState;
1185    use crate::printer::{PrinterConfig, PrinterRecord, PrinterRegistry};
1186    use crate::raster::{JobFailure, JobOutcome};
1187    use std::sync::atomic::{AtomicUsize, Ordering};
1188
1189    /// The logical slug must match what CUPS's `cups_queue_name` derives from
1190    /// the same (spaced, mixed-case) DNS-SD instance name, modulo case — CUPS's
1191    /// printer lookup is case-insensitive. CUPS maps each non-alnum run to one
1192    /// `_` and trims; we additionally lowercase.
1193    #[test]
1194    fn slug_matches_cups_queue_name_modulo_case() {
1195        assert_eq!(
1196            printer_name_from_uri("supvan://x", "Supvan T50 Series t0117a2410211517"),
1197            "supvan_t50_series_t0117a2410211517"
1198        );
1199        // Collapses runs of separators and trims leading/trailing ones.
1200        assert_eq!(
1201            printer_name_from_uri("", "  Brother  HL-2270DW  "),
1202            "brother_hl_2270dw"
1203        );
1204        // Falls back to the URI when info is empty, and never yields empty.
1205        assert_eq!(printer_name_from_uri("supvan://t0117", ""), "supvan_t0117");
1206        assert_eq!(printer_name_from_uri("", "***"), "printer");
1207    }
1208
1209    pub(super) struct NoopBackend;
1210    #[async_trait::async_trait]
1211    impl DeviceBackend for NoopBackend {
1212        async fn list(&self) -> Vec<crate::device::DiscoveredDevice> {
1213            Vec::new()
1214        }
1215        fn driver_for_device(&self, _id: &str, _uri: &str) -> Option<String> {
1216            None
1217        }
1218    }
1219
1220    fn test_config(name: &str) -> PrinterConfig {
1221        PrinterConfig {
1222            name: name.into(),
1223            display_name: String::new(),
1224            driver_name: "test".into(),
1225            make_and_model: "Test".into(),
1226            device_id: String::new(),
1227            device_uri: "mock://x".into(),
1228            dpi: 203,
1229            printhead_width_dots: 384,
1230            media_names: vec![],
1231            media_sizes: vec![],
1232            media_size_min: [0, 0],
1233            media_size_max: [0, 0],
1234            darkness: 50,
1235            document_formats: vec![],
1236        }
1237    }
1238
1239    fn test_state(print_job: PrintJobFn, tag: &str) -> AppState {
1240        let registry: PrinterRegistry =
1241            Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(
1242                test_config("p"),
1243            )]));
1244        AppState {
1245            host: "127.0.0.1".into(),
1246            port: 0,
1247            printers: registry,
1248            print_job,
1249            media_change: None,
1250            state_path: std::env::temp_dir().join(format!("ipp-worker-test-{tag}.json")),
1251            jobs: crate::job::JobRegistry::new(),
1252            device_backend: Arc::new(NoopBackend),
1253        }
1254    }
1255
1256    /// Drive a job to a terminal state, polling the registry. Returns the final
1257    /// job state (or panics on timeout).
1258    async fn run_to_terminal(state: &AppState, id: crate::job::JobId) -> JobState {
1259        for _ in 0..500 {
1260            let s = state.jobs.get(id).unwrap().state;
1261            if matches!(
1262                s,
1263                JobState::Completed | JobState::Aborted | JobState::Canceled
1264            ) {
1265                return s;
1266            }
1267            tokio::time::sleep(Duration::from_millis(10)).await;
1268        }
1269        panic!("job {id} did not reach a terminal state");
1270    }
1271
1272    /// A `DeviceUnavailable` outcome must HOLD the job and retry it until the
1273    /// device prints — the paper-jam / offline behavior — not abort it.
1274    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1275    async fn device_unavailable_holds_and_retries_until_it_prints() {
1276        std::env::set_var("IPP_PRINTER_APP_RETRY_MS", "5");
1277        let attempts = Arc::new(AtomicUsize::new(0));
1278        let a = attempts.clone();
1279        let print_job: PrintJobFn = Arc::new(move |_ctx, _payload, _copies| {
1280            let a = a.clone();
1281            Box::pin(async move {
1282                // Unavailable for the first two attempts, then it prints.
1283                if a.fetch_add(1, Ordering::SeqCst) < 2 {
1284                    JobOutcome::DeviceUnavailable {
1285                        reasons: PrinterReason::OFFLINE,
1286                    }
1287                } else {
1288                    JobOutcome::Completed
1289                }
1290            })
1291        });
1292        let state = test_state(print_job, "hold");
1293        let job = state.jobs.create("p".into(), "tester".into());
1294        let id = job.id;
1295        spawn_print_worker(
1296            &state,
1297            "p".into(),
1298            job,
1299            vec![1, 2, 3],
1300            1,
1301            "image/pwg-raster".into(),
1302            None,
1303        );
1304
1305        assert_eq!(
1306            run_to_terminal(&state, id).await,
1307            JobState::Completed,
1308            "a held job must eventually print, not abort"
1309        );
1310        assert!(
1311            attempts.load(Ordering::SeqCst) >= 3,
1312            "the framework should have retried the held job"
1313        );
1314    }
1315
1316    /// A `Failed` outcome is a permanent abort — no retry.
1317    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1318    async fn failed_outcome_aborts_without_retry() {
1319        std::env::set_var("IPP_PRINTER_APP_RETRY_MS", "5");
1320        let attempts = Arc::new(AtomicUsize::new(0));
1321        let a = attempts.clone();
1322        let print_job: PrintJobFn = Arc::new(move |_ctx, _payload, _copies| {
1323            let a = a.clone();
1324            Box::pin(async move {
1325                a.fetch_add(1, Ordering::SeqCst);
1326                JobOutcome::Failed(JobFailure::other("unsupported document"))
1327            })
1328        });
1329        let state = test_state(print_job, "fail");
1330        let job = state.jobs.create("p".into(), "tester".into());
1331        let id = job.id;
1332        spawn_print_worker(&state, "p".into(), job, vec![0], 1, "x".into(), None);
1333
1334        assert_eq!(run_to_terminal(&state, id).await, JobState::Aborted);
1335        assert_eq!(
1336            attempts.load(Ordering::SeqCst),
1337            1,
1338            "a Failed outcome must not be retried"
1339        );
1340    }
1341
1342    /// Canceling a held job stops the retry loop promptly.
1343    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1344    async fn canceling_a_held_job_stops_the_retries() {
1345        std::env::set_var("IPP_PRINTER_APP_RETRY_MS", "5");
1346        let print_job: PrintJobFn = Arc::new(|_ctx, _payload, _copies| {
1347            // Never recovers — only a cancel can end this.
1348            Box::pin(async {
1349                JobOutcome::DeviceUnavailable {
1350                    reasons: PrinterReason::MEDIA_JAM,
1351                }
1352            })
1353        });
1354        let state = test_state(print_job, "cancel");
1355        let job = state.jobs.create("p".into(), "tester".into());
1356        let id = job.id;
1357        let cancel = job.cancel_flag.clone();
1358        spawn_print_worker(&state, "p".into(), job, vec![0], 1, "x".into(), None);
1359        // Let it hold a couple of rounds, then cancel.
1360        tokio::time::sleep(Duration::from_millis(40)).await;
1361        state.jobs.cancel(id);
1362        let _ = cancel; // cancel() set the flag the worker polls
1363        assert_eq!(run_to_terminal(&state, id).await, JobState::Canceled);
1364    }
1365}
1366
1367#[cfg(test)]
1368mod media_tests {
1369    use super::tests::NoopBackend;
1370    use super::*;
1371    use crate::printer::{PrinterConfig, PrinterRecord, PrinterRegistry};
1372
1373    fn config(min: [i32; 2], max: [i32; 2]) -> PrinterConfig {
1374        PrinterConfig {
1375            name: "p".into(),
1376            display_name: String::new(),
1377            driver_name: "test".into(),
1378            make_and_model: "Test".into(),
1379            device_id: String::new(),
1380            device_uri: "mock://x".into(),
1381            dpi: 203,
1382            printhead_width_dots: 384,
1383            media_names: vec!["om_40x30mm_40x30mm".into()],
1384            media_sizes: vec![[4000, 3000]],
1385            media_size_min: min,
1386            media_size_max: max,
1387            darkness: 50,
1388            document_formats: vec![],
1389        }
1390    }
1391
1392    fn state_with(cfg: PrinterConfig, hook: Option<MediaChangeFn>, tag: &str) -> AppState {
1393        let registry: PrinterRegistry =
1394            Arc::new(parking_lot::RwLock::new(vec![PrinterRecord::new(cfg)]));
1395        AppState {
1396            host: "127.0.0.1".into(),
1397            port: 0,
1398            printers: registry,
1399            print_job: Arc::new(|_, _, _| Box::pin(async { JobOutcome::Completed })),
1400            media_change: hook,
1401            state_path: std::env::temp_dir().join(format!("ipp-media-test-{tag}.json")),
1402            jobs: crate::job::JobRegistry::new(),
1403            device_backend: Arc::new(NoopBackend),
1404        }
1405    }
1406
1407    async fn apply(state: &AppState, w: u32, h: u32) -> StatusCode {
1408        media_apply_handler(
1409            State(state.clone()),
1410            Path("p".to_string()),
1411            Form(MediaForm {
1412                width_mm: w,
1413                height_mm: h,
1414            }),
1415        )
1416        .await
1417        .into_response()
1418        .status()
1419    }
1420
1421    /// A refusal must leave the config exactly as it was — the whole point of
1422    /// the hook is that the consumer can veto a size the device won't accept.
1423    #[tokio::test]
1424    async fn refused_change_is_not_persisted() {
1425        let hook: MediaChangeFn =
1426            Arc::new(|_, _| Box::pin(async { Err("genuine RFID roll loaded".to_string()) }));
1427        let state = state_with(config([0, 0], [0, 0]), Some(hook), "refuse");
1428
1429        assert_eq!(apply(&state, 34, 34).await, StatusCode::CONFLICT);
1430
1431        let printers = state.printers.read();
1432        assert!(printers[0].ready_media.is_none(), "ready_media was written");
1433        assert_eq!(printers[0].config.media_names.len(), 1, "list grew");
1434    }
1435
1436    /// An accepted change publishes the media and extends the enumerated list,
1437    /// so clients reading only `media-supported` can select it too.
1438    #[tokio::test]
1439    async fn accepted_change_publishes_and_extends_list() {
1440        let state = state_with(config([1000, 1000], [5000, 12000]), None, "accept");
1441
1442        assert_eq!(apply(&state, 34, 34).await, StatusCode::OK);
1443
1444        let printers = state.printers.read();
1445        let ready = printers[0].ready_media.as_ref().expect("ready_media unset");
1446        assert_eq!(ready.size_hmm, [3400, 3400]);
1447        assert_eq!(ready.name, "om_34x34mm_34x34mm");
1448        assert!(printers[0]
1449            .config
1450            .media_names
1451            .contains(&"om_34x34mm_34x34mm".to_string()));
1452    }
1453
1454    /// Out-of-range sizes are rejected before the hook runs, so a consumer
1455    /// never sees a size its own config already rules out.
1456    #[tokio::test]
1457    async fn out_of_range_is_rejected_without_calling_the_hook() {
1458        let called = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1459        let seen = called.clone();
1460        let hook: MediaChangeFn = Arc::new(move |_, _| {
1461            seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1462            Box::pin(async { Ok(()) })
1463        });
1464        let state = state_with(config([1000, 1000], [5000, 12000]), Some(hook), "range");
1465
1466        assert_eq!(apply(&state, 200, 34).await, StatusCode::BAD_REQUEST);
1467        assert_eq!(called.load(std::sync::atomic::Ordering::SeqCst), 0);
1468    }
1469
1470    /// Re-applying the same size must not grow the list on every submit.
1471    #[tokio::test]
1472    async fn repeated_apply_does_not_duplicate() {
1473        let state = state_with(config([1000, 1000], [5000, 12000]), None, "dup");
1474        apply(&state, 34, 34).await;
1475        apply(&state, 34, 34).await;
1476        assert_eq!(state.printers.read()[0].config.media_names.len(), 2);
1477    }
1478
1479    /// Config values reach the page as HTML; a name carrying markup must not
1480    /// escape into it.
1481    #[test]
1482    fn page_escapes_config_values() {
1483        let mut cfg = config([0, 0], [0, 0]);
1484        cfg.display_name = "<script>x</script>".into();
1485        let page = media_page(&PrinterRecord::new(cfg), None, false);
1486        assert!(!page.contains("<script>"));
1487        assert!(page.contains("&lt;script&gt;"));
1488    }
1489}