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;
5
6use axum::body::Bytes;
7use axum::extract::{Path, State};
8use axum::http::{header, StatusCode};
9use axum::response::IntoResponse;
10use axum::routing::{get, post};
11use axum::Router;
12use ipp::model::Operation;
13use ipp::parser::IppParser;
14use ipp::model::StatusCode as IppStatus;
15use ipp::prelude::*;
16use ipp::reader::IppReader;
17use num_traits::FromPrimitive;
18use crate::attributes::{
19    self, build_get_jobs_response, build_job_attrs_response, get_printer_attributes,
20    print_job_accepted, validate_job,
21};
22use crate::device::DeviceBackend;
23use crate::job::{JobId, JobRegistry, JobState};
24use crate::printer::{PrinterRecord, PrinterRegistry};
25use crate::raster::JobFailure;
26use crate::state::PersistedState;
27
28/// Context passed to a print-job worker so it can observe cancellation and
29/// report progress without re-querying the registry.
30#[derive(Clone)]
31#[allow(missing_docs)]
32pub struct JobContext {
33    pub id: JobId,
34    pub printer_name: String,
35    pub cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
36    /// The `document-format` the client sent (RFC 8011), e.g.
37    /// `image/pwg-raster` or `image/jpeg`. Defaults to
38    /// `application/octet-stream` when the client omits it. The print
39    /// callback branches on this to pick a decoder.
40    pub document_format: String,
41}
42
43/// Callback to process a CUPS raster document on a device.
44///
45/// Returning `Err(JobFailure)` lets the framework propagate
46/// `job-state-reasons` / `job-state-message` to IPP clients.
47pub type PrintJobFn = Arc<
48    dyn Fn(JobContext, Vec<u8>, u32) -> Result<(), JobFailure>
49        + Send
50        + Sync,
51>;
52
53/// Server configuration. Construct in your `main`, hand to [`Server::run`].
54#[allow(missing_docs)]
55pub struct ServerOptions {
56    pub host: String,
57    pub port: u16,
58    pub printers: PrinterRegistry,
59    pub device_backend: Arc<dyn DeviceBackend>,
60    pub print_job: PrintJobFn,
61    pub state_path: std::path::PathBuf,
62    /// When `true` (and the `mdns` feature is on), [`Server::run`] starts the
63    /// DNS-SD advertiser itself, immediately, from the registry as it stands
64    /// at bind time. Set `false` if the caller needs to advertise later —
65    /// e.g. after assigning each [`crate::printer::PrinterRecord::uuid`] from
66    /// an external source (a CUPS queue's `printer-uuid`) so the advertised
67    /// `UUID=` matches a local queue and cups-browsed dedupes it. The caller
68    /// is then responsible for calling [`crate::mdns::Advertiser::register_all`]
69    /// and holding the handle.
70    pub advertise_mdns: bool,
71}
72
73/// Axum-shared state. Constructed internally by [`Server::router`]; exposed
74/// only so external middleware can read the printer registry.
75#[derive(Clone)]
76#[allow(missing_docs)]
77pub struct AppState {
78    pub host: String,
79    pub port: u16,
80    pub printers: PrinterRegistry,
81    pub print_job: PrintJobFn,
82    pub state_path: std::path::PathBuf,
83    pub jobs: JobRegistry,
84    pub device_backend: Arc<dyn DeviceBackend>,
85}
86
87/// Entry point — `Server::run(opts).await` starts the listener.
88pub struct Server;
89
90impl Server {
91    /// Build the axum router with the configured state attached. Returned
92    /// router can be served via [`Server::run`] or by hand.
93    pub fn router(opts: ServerOptions) -> Router {
94        let state = AppState {
95            host: opts.host.clone(),
96            port: opts.port,
97            printers: opts.printers.clone(),
98            print_job: opts.print_job,
99            state_path: opts.state_path,
100            jobs: JobRegistry::new(),
101            device_backend: opts.device_backend,
102        };
103
104        Router::new()
105            .route("/", get(index_handler))
106            .route("/icon.png", get(icon_handler))
107            .route("/ipp/print/{name}", post(ipp_handler))
108            .route("/ipp/print/{name}/", post(ipp_handler))
109            .with_state(state)
110    }
111
112    /// Bind to `host:port`, spawn the background status poller (and the mDNS
113    /// advertiser if the `mdns` feature is enabled), and run the axum
114    /// listener until it errors.
115    pub async fn run(opts: ServerOptions) -> std::io::Result<()> {
116        let addr = format!("{}:{}", opts.host, opts.port);
117        let listener = tokio::net::TcpListener::bind(&addr).await?;
118        log::info!("ipp-printer-app listening on http://{addr}");
119
120        // Background status poller — keeps printer-state-reasons fresh.
121        let _status = crate::status::spawn(opts.device_backend.clone(), opts.printers.clone());
122
123        // mDNS advertising for IPP-Everywhere auto-discovery. Skipped when the
124        // caller opts to advertise itself later (see ServerOptions::advertise_mdns).
125        #[cfg(feature = "mdns")]
126        let _advertiser = if opts.advertise_mdns {
127            match crate::mdns::Advertiser::register_all(&opts.printers, opts.port) {
128                Ok(adv) => Some(adv),
129                Err(e) => {
130                    log::warn!("mdns: failed to register printers: {e}");
131                    None
132                }
133            }
134        } else {
135            None
136        };
137
138        axum::serve(listener, Self::router(opts)).await
139    }
140
141    /// Load printers from disk, discover devices, merge into registry.
142    pub fn bootstrap_printers(
143        registry: &PrinterRegistry,
144        backend: &dyn DeviceBackend,
145        state_path: &std::path::Path,
146        make_config: impl Fn(&str, &str, &str, &str) -> Option<crate::printer::PrinterConfig>,
147    ) {
148        let mut records: Vec<PrinterRecord> = PersistedState::load(state_path)
149            .printers
150            .into_iter()
151            .map(PrinterRecord::new)
152            .collect();
153
154        backend.list(&mut |info, uri, device_id| {
155            let driver = match backend.driver_for_device(device_id, uri) {
156                Some(d) => d,
157                None => return true,
158            };
159            let name = printer_name_from_uri(uri, info);
160            if records.iter().any(|r| r.config.device_uri == uri) {
161                return true;
162            }
163            let Some(cfg) = make_config(&name, &driver, uri, device_id) else {
164                return true;
165            };
166            log::info!("auto-add printer {name} -> {uri}");
167            records.push(PrinterRecord::new(cfg));
168            true
169        });
170
171        *registry.write() = records;
172        Self::persist(registry, state_path);
173    }
174
175    /// Snapshot the registry to `state_path` as JSON. Called automatically
176    /// at the end of every print job; expose for callers that want to
177    /// persist after manual registry edits.
178    pub fn persist(registry: &PrinterRegistry, state_path: &std::path::Path) {
179        let configs: Vec<_> = registry
180            .read()
181            .iter()
182            .map(|r| r.config.clone())
183            .collect();
184        let _ = PersistedState { printers: configs }.save(state_path);
185    }
186}
187
188/// Generic slug used as the proposed printer name during bootstrap. The
189/// `make_config` callback receives this as its first arg and is free to
190/// override by returning a [`PrinterConfig`] with a different `name`.
191fn printer_name_from_uri(uri: &str, info: &str) -> String {
192    let source = if info.is_empty() { uri } else { info };
193    let slug: String = source
194        .chars()
195        .map(|c| {
196            if c.is_ascii_alphanumeric() {
197                c.to_ascii_lowercase()
198            } else {
199                '-'
200            }
201        })
202        .collect();
203    let trimmed = slug.trim_matches('-');
204    let collapsed: String = trimmed
205        .split('-')
206        .filter(|s| !s.is_empty())
207        .collect::<Vec<_>>()
208        .join("-");
209    if collapsed.is_empty() {
210        "printer".to_string()
211    } else {
212        collapsed
213    }
214}
215
216async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
217    let printers = state.printers.read();
218    let mut html = String::from(
219        "<!DOCTYPE html><html><head><title>ipp-printer-app</title></head><body>\
220         <h1>ipp-printer-app</h1><ul>",
221    );
222    for p in printers.iter() {
223        let uri = p.config.printer_uri(&state.host, state.port);
224        html.push_str(&format!(
225            "<li><b>{}</b> — <code>{uri}</code> — device <code>{}</code></li>",
226            p.config.name, p.config.device_uri
227        ));
228    }
229    html.push_str(&format!(
230        "</ul><p>Register with CUPS: <code>lpadmin -p NAME -E -v \
231         ipp://{}:{}/ipp/print/NAME -m everywhere</code></p></body></html>",
232        if state.host.is_empty() || state.host == "0.0.0.0" || state.host == "::" {
233            "localhost"
234        } else {
235            &state.host
236        },
237        state.port,
238    ));
239    (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html)
240}
241
242/// Serve the printer icon advertised in `printer-icons`. A 1×1 transparent
243/// PNG keeps the resource valid without shipping artwork; consumers that want
244/// a real icon can layer their own route ahead of this one.
245async fn icon_handler() -> impl IntoResponse {
246    const ICON_PNG: &[u8] = &[
247        0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
248        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
249        0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x62, 0x00,
250        0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
251        0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
252    ];
253    (
254        StatusCode::OK,
255        [(header::CONTENT_TYPE, "image/png")],
256        ICON_PNG,
257    )
258}
259
260async fn ipp_handler(
261    State(state): State<AppState>,
262    Path(name): Path<String>,
263    body: Bytes,
264) -> impl IntoResponse {
265    match handle_ipp(&state, &name, &body) {
266        Ok(bytes) => (
267            StatusCode::OK,
268            [(header::CONTENT_TYPE, "application/ipp")],
269            bytes,
270        ),
271        Err((status, msg)) => (
272            status,
273            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
274            msg.into_bytes(),
275        ),
276    }
277}
278
279fn handle_ipp(state: &AppState, name: &str, body: &[u8]) -> Result<Vec<u8>, (StatusCode, String)> {
280    let mut req = IppParser::new(IppReader::new(Cursor::new(body.to_vec())))
281        .parse()
282        .map_err(|e| (StatusCode::BAD_REQUEST, format!("IPP parse error: {e}")))?;
283
284    let version = req.header().version;
285    let request_id = req.header().request_id;
286    let op_code = req.header().operation_or_status;
287
288    // RFC 8011 §4.1.8: reject IPP versions outside the 1.x / 2.x families.
289    let major = version.0 >> 8;
290    if major != 1 && major != 2 {
291        let resp = IppRequestResponse::new_response(
292            IppVersion::v1_1(),
293            IppStatus::ServerErrorVersionNotSupported,
294            request_id,
295        )
296        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
297        return Ok(resp.to_bytes().to_vec());
298    }
299
300    // RFC 8011 §4.1.4: the operation-attributes group must begin with
301    // `attributes-charset` followed by `attributes-natural-language`, in that
302    // order. The `ipp` crate parses attributes into a hash map (losing wire
303    // order), so we check the first two names against the raw request bytes.
304    // Wrong order / missing → `client-error-bad-request`.
305    if !operation_attributes_well_ordered(body) {
306        let resp = IppRequestResponse::new_response(
307            version,
308            IppStatus::ClientErrorBadRequest,
309            request_id,
310        )
311        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
312        return Ok(resp.to_bytes().to_vec());
313    }
314
315    // RFC 8011 §4.1.1: a request-id of 0 is invalid and must be rejected with
316    // `client-error-bad-request`. We answer in-band (HTTP 200 + IPP status) so
317    // conformant clients see the IPP error rather than a transport failure.
318    if request_id == 0 {
319        let resp = IppRequestResponse::new_response(
320            version,
321            IppStatus::ClientErrorBadRequest,
322            request_id,
323        )
324        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
325        return Ok(resp.to_bytes().to_vec());
326    }
327
328    // RFC 8011 §4.2: an operation must target the printer (or a job) via a
329    // `printer-uri` (or `job-uri`) operation attribute. We still route by the
330    // request path, but a request carrying neither is malformed.
331    if !has_operation_attr(&req, "printer-uri") && !has_operation_attr(&req, "job-uri") {
332        let resp = IppRequestResponse::new_response(
333            version,
334            IppStatus::ClientErrorBadRequest,
335            request_id,
336        )
337        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
338        return Ok(resp.to_bytes().to_vec());
339    }
340
341    let record = {
342        let guard = state.printers.read();
343        guard
344            .iter()
345            .find(|p| p.config.name == name)
346            .cloned()
347            .ok_or((StatusCode::NOT_FOUND, format!("printer not found: {name}")))?
348    };
349
350    // PWG 5100.14 required operations that the `ipp` crate's `Operation` enum
351    // doesn't model. Dispatch them by raw code before the enum conversion.
352    const OP_CANCEL_MY_JOBS: u16 = 0x0039;
353    const OP_CLOSE_JOB: u16 = 0x003b;
354    const OP_IDENTIFY_PRINTER: u16 = 0x003c;
355    match op_code {
356        OP_CLOSE_JOB => {
357            // We finalize jobs eagerly on Send-Document, so Close-Job is an
358            // acknowledgement — succeed if the job exists.
359            let status = match extract_job_id(&req).and_then(|id| state.jobs.get(id)) {
360                Some(_) => IppStatus::SuccessfulOk,
361                None => IppStatus::ClientErrorNotFound,
362            };
363            let resp = IppRequestResponse::new_response(version, status, request_id)
364                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
365            return Ok(resp.to_bytes().to_vec());
366        }
367        OP_CANCEL_MY_JOBS => {
368            for j in state.jobs.jobs_for_printer(name) {
369                state.jobs.cancel(j.id);
370            }
371            let resp =
372                IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
373                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
374            return Ok(resp.to_bytes().to_vec());
375        }
376        OP_IDENTIFY_PRINTER => {
377            let actions = extract_identify_actions(&req);
378            state.device_backend.identify(&record.config, &actions);
379            let resp =
380                IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
381                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
382            return Ok(resp.to_bytes().to_vec());
383        }
384        _ => {}
385    }
386
387    let op = Operation::from_u16(op_code)
388        .ok_or((StatusCode::BAD_REQUEST, "unknown IPP operation".into()))?;
389
390    let resp = match op {
391        Operation::GetPrinterAttributes => {
392            let requested = extract_requested_attributes(&req);
393            get_printer_attributes(
394                version,
395                request_id,
396                &record,
397                &state.host,
398                state.port,
399                requested.as_ref(),
400            )
401            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
402        }
403        Operation::ValidateJob => validate_job(version, request_id, &record, &state.host, state.port)
404            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?,
405        Operation::PrintJob => {
406            let copies = extract_copies(&req);
407            let format = extract_document_format(&req);
408            let mut payload = Vec::new();
409            req.payload_mut()
410                .read_to_end(&mut payload)
411                .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
412
413            let job = state.jobs.create(name.to_string(), requesting_user(&req));
414            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
415            let accepted = print_job_accepted(version, request_id, &job, &printer_uri_str)
416                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
417
418            spawn_print_worker(state, name.to_string(), job, payload, copies, format);
419            accepted
420        }
421        Operation::CreateJob => {
422            // Document-less job creation; the document arrives via Send-Document.
423            let job = state.jobs.create(name.to_string(), requesting_user(&req));
424            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
425            print_job_accepted(version, request_id, &job, &printer_uri_str)
426                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
427        }
428        Operation::SendDocument => {
429            // Attach a document to an existing (Create-Job) job and, on the
430            // last document, hand it to the print worker. We don't support
431            // multi-document jobs (multiple-document-jobs-supported = false),
432            // so each Send-Document carries the whole job.
433            // RFC 8011 §3.3.1: Send-Document requires the `last-document`
434            // boolean operation attribute.
435            if !has_operation_attr(&req, "last-document") {
436                let resp = IppRequestResponse::new_response(
437                    version,
438                    IppStatus::ClientErrorBadRequest,
439                    request_id,
440                )
441                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
442                return Ok(resp.to_bytes().to_vec());
443            }
444            let job_id = extract_job_id(&req).ok_or((
445                StatusCode::BAD_REQUEST,
446                "Send-Document missing job-id".to_string(),
447            ))?;
448            let job = state.jobs.get(job_id).ok_or((
449                StatusCode::NOT_FOUND,
450                format!("job not found: {job_id}"),
451            ))?;
452            let copies = extract_copies(&req);
453            let format = extract_document_format(&req);
454            let mut payload = Vec::new();
455            req.payload_mut()
456                .read_to_end(&mut payload)
457                .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
458
459            if !payload.is_empty() {
460                spawn_print_worker(state, name.to_string(), job.clone(), payload, copies, format);
461            }
462            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
463            build_job_attrs_response(version, request_id, &job, &printer_uri_str, None)
464                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
465        }
466        Operation::GetJobs => {
467            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
468            let mut jobs = state.jobs.jobs_for_printer(name);
469            // RFC 8011 §3.2.6.1: `my-jobs=true` scopes the listing to the
470            // requesting user's own jobs.
471            if my_jobs_flag(&req) {
472                let user = requesting_user(&req);
473                jobs.retain(|j| j.owner == user);
474            }
475            // When the client omits `requested-attributes`, Get-Jobs returns
476            // only `job-uri` and `job-id`.
477            let requested = extract_requested_attributes(&req);
478            let default_set = ["job-uri".to_string(), "job-id".to_string()]
479                .into_iter()
480                .collect();
481            let filter = effective_filter(requested.as_ref(), &default_set);
482            build_get_jobs_response(version, request_id, &jobs, &printer_uri_str, filter)
483                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
484        }
485        Operation::GetJobAttributes => {
486            let printer_uri_str = record.config.printer_uri(&state.host, state.port);
487            let job_id = extract_job_id(&req).ok_or((
488                StatusCode::BAD_REQUEST,
489                "Get-Job-Attributes missing job-id".to_string(),
490            ))?;
491            let job = state.jobs.get(job_id).ok_or((
492                StatusCode::NOT_FOUND,
493                format!("job not found: {job_id}"),
494            ))?;
495            // Get-Job-Attributes default is "all" — filter only when the
496            // client supplies a concrete `requested-attributes` set.
497            let requested = extract_requested_attributes(&req);
498            let all = std::collections::BTreeSet::new();
499            let filter = effective_filter(requested.as_ref(), &all);
500            build_job_attrs_response(version, request_id, &job, &printer_uri_str, filter)
501                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
502        }
503        Operation::CancelJob => {
504            let job_id = extract_job_id(&req).ok_or((
505                StatusCode::BAD_REQUEST,
506                "Cancel-Job missing job-id".to_string(),
507            ))?;
508            let status = match state.jobs.cancel(job_id) {
509                None => IppStatus::ClientErrorNotFound,
510                Some(JobState::Canceled) => IppStatus::SuccessfulOk,
511                Some(_) => IppStatus::ClientErrorNotPossible,
512            };
513            IppRequestResponse::new_response(version, status, request_id)
514                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
515        }
516        _ => {
517            return Err((
518                StatusCode::BAD_REQUEST,
519                format!("unsupported IPP operation: {op:?}"),
520            ));
521        }
522    };
523
524    Ok(resp.to_bytes().to_vec())
525}
526
527/// RFC 8011 §4.1.4: the operation-attributes group must start with
528/// `attributes-charset` then `attributes-natural-language`, in that order.
529/// Parses the first two attribute *names* directly from the raw IPP request
530/// (header is 8 bytes; the operation group is the first delimiter group) and
531/// checks them. Returns `false` on a short/malformed buffer.
532fn operation_attributes_well_ordered(body: &[u8]) -> bool {
533    // header: version(2) operation(2) request-id(4), then delimiter tag.
534    if body.len() <= 8 || body[8] != ipp::model::DelimiterTag::OperationAttributes as u8 {
535        return false;
536    }
537    let mut i = 9;
538    let mut names: Vec<&[u8]> = Vec::new();
539    while i < body.len() && names.len() < 2 {
540        let tag = body[i];
541        if tag <= 0x0f {
542            break; // next delimiter tag → end of operation group
543        }
544        i += 1;
545        if i + 2 > body.len() {
546            return false;
547        }
548        let name_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
549        i += 2;
550        if i + name_len > body.len() {
551            return false;
552        }
553        // name_len == 0 marks an additional value of the previous attribute.
554        if name_len > 0 {
555            names.push(&body[i..i + name_len]);
556        }
557        i += name_len;
558        if i + 2 > body.len() {
559            return false;
560        }
561        let value_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
562        i += 2 + value_len;
563    }
564    names.first() == Some(&b"attributes-charset".as_slice())
565        && names.get(1) == Some(&b"attributes-natural-language".as_slice())
566}
567
568/// True if an attribute named `name` is present in the operation-attributes
569/// group of `req`.
570fn has_operation_attr(req: &IppRequestResponse, name: &str) -> bool {
571    req.attributes()
572        .groups()
573        .iter()
574        .filter(|g| g.tag() == ipp::model::DelimiterTag::OperationAttributes)
575        .any(|g| g.attributes().keys().any(|k| k.as_str() == name))
576}
577
578fn extract_job_id(req: &IppRequestResponse) -> Option<JobId> {
579    for group in req.attributes().groups() {
580        for attr in group.attributes().values() {
581            if attr.name().as_str() == "job-id" {
582                if let IppValue::Integer(n) = attr.value() {
583                    return Some((*n) as JobId);
584                }
585            }
586            if attr.name().as_str() == "job-uri" {
587                if let IppValue::Uri(s) = attr.value() {
588                    return s.as_str().rsplit('/').next().and_then(|s| s.parse().ok());
589                }
590            }
591        }
592    }
593    None
594}
595
596fn extract_copies(req: &IppRequestResponse) -> u32 {
597    for group in req.attributes().groups() {
598        for attr in group.attributes().values() {
599            if attr.name().as_str() == "copies" {
600                if let IppValue::Integer(n) = attr.value() {
601                    return (*n).max(1) as u32;
602                }
603            }
604        }
605    }
606    0
607}
608
609/// Read the `document-format` operation attribute. Defaults to
610/// `application/octet-stream` (the IPP default) when the client omits it.
611fn extract_document_format(req: &IppRequestResponse) -> String {
612    for group in req.attributes().groups() {
613        for attr in group.attributes().values() {
614            if attr.name().as_str() == "document-format" {
615                if let IppValue::MimeMediaType(s) = attr.value() {
616                    return s.as_str().to_string();
617                }
618            }
619        }
620    }
621    "application/octet-stream".to_string()
622}
623
624/// Collect the client's `requested-attributes` values (RFC 8011 §4.2.5).
625/// Returns `None` when the attribute is absent (caller treats that as "all").
626fn extract_requested_attributes(req: &IppRequestResponse) -> Option<std::collections::BTreeSet<String>> {
627    for group in req.attributes().groups() {
628        for attr in group.attributes().values() {
629            if attr.name().as_str() == "requested-attributes" {
630                let mut set = std::collections::BTreeSet::new();
631                for v in attr.value().into_iter() {
632                    if let IppValue::Keyword(k) = v {
633                        set.insert(k.as_str().to_string());
634                    }
635                }
636                return Some(set);
637            }
638        }
639    }
640    None
641}
642
643/// Resolve the effective attribute filter for a job query. `requested` is the
644/// client's `requested-attributes` (if any); `default` is the operation's
645/// default set (empty = "all"). Returns `None` to mean "return everything".
646fn effective_filter<'a>(
647    requested: Option<&'a std::collections::BTreeSet<String>>,
648    default: &'a std::collections::BTreeSet<String>,
649) -> Option<&'a std::collections::BTreeSet<String>> {
650    match requested {
651        None => (!default.is_empty()).then_some(default),
652        Some(set) if set.is_empty() || set.contains("all") => None,
653        Some(set) => Some(set),
654    }
655}
656
657/// Read `requesting-user-name` from the operation attributes, defaulting to
658/// `anonymous` when the client omits it.
659fn requesting_user(req: &IppRequestResponse) -> String {
660    for group in req.attributes().groups() {
661        for attr in group.attributes().values() {
662            if attr.name().as_str() == "requesting-user-name" {
663                if let IppValue::NameWithoutLanguage(s) = attr.value() {
664                    return s.as_str().to_string();
665                }
666            }
667        }
668    }
669    "anonymous".to_string()
670}
671
672/// Read the `my-jobs` boolean operation attribute (default `false`).
673fn my_jobs_flag(req: &IppRequestResponse) -> bool {
674    for group in req.attributes().groups() {
675        for attr in group.attributes().values() {
676            if attr.name().as_str() == "my-jobs" {
677                if let IppValue::Boolean(b) = attr.value() {
678                    return *b;
679                }
680            }
681        }
682    }
683    false
684}
685
686/// Collect `identify-actions` keywords from an Identify-Printer request.
687fn extract_identify_actions(req: &IppRequestResponse) -> Vec<String> {
688    for group in req.attributes().groups() {
689        for attr in group.attributes().values() {
690            if attr.name().as_str() == "identify-actions" {
691                return attr
692                    .value()
693                    .into_iter()
694                    .filter_map(|v| match v {
695                        IppValue::Keyword(k) => Some(k.as_str().to_string()),
696                        _ => None,
697                    })
698                    .collect();
699            }
700        }
701    }
702    Vec::new()
703}
704
705/// Spawn the background worker that runs a print job to completion, updating
706/// printer/job state and persisting at the end. Shared by Print-Job and
707/// Send-Document.
708fn spawn_print_worker(
709    state: &AppState,
710    printer_name: String,
711    job: crate::job::JobRecord,
712    payload: Vec<u8>,
713    copies: u32,
714    document_format: String,
715) {
716    let state_clone = state.clone();
717    let name_owned = printer_name;
718    let job_for_worker = job;
719    std::thread::spawn(move || {
720        {
721            let mut guard = state_clone.printers.write();
722            if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
723                attributes::set_printer_processing(p);
724            }
725        }
726        state_clone
727            .jobs
728            .set_state(job_for_worker.id, JobState::Processing);
729        let ctx = JobContext {
730            id: job_for_worker.id,
731            printer_name: name_owned.clone(),
732            cancel_flag: job_for_worker.cancel_flag.clone(),
733            document_format,
734        };
735        let result = (state_clone.print_job)(ctx, payload, copies);
736        {
737            let mut guard = state_clone.printers.write();
738            if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
739                attributes::set_printer_idle(p);
740                match &result {
741                    Ok(()) => p.reasons = crate::flags::PrinterReason::empty(),
742                    Err(f) => p.reasons = f.printer_reasons,
743                }
744            }
745        }
746        match result {
747            Ok(()) => {
748                // Don't clobber a Cancel that landed while the worker was
749                // running — the registry already saw it.
750                if !job_for_worker.cancel_flag.load(std::sync::atomic::Ordering::Acquire) {
751                    state_clone
752                        .jobs
753                        .set_state(job_for_worker.id, JobState::Completed);
754                }
755            }
756            Err(f) => {
757                log::error!(
758                    "print job {} failed: {} (reasons={:?})",
759                    job_for_worker.id,
760                    f.message,
761                    f.printer_reasons,
762                );
763                state_clone
764                    .jobs
765                    .set_failure(job_for_worker.id, f.printer_reasons, f.message);
766            }
767        }
768        Server::persist(&state_clone.printers, &state_clone.state_path);
769    });
770}