1use 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#[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 pub document_format: String,
43 pub print_quality: Option<u8>,
50}
51
52impl JobContext {
53 pub fn is_canceled(&self) -> bool {
56 self.cancel_flag.load(std::sync::atomic::Ordering::Acquire)
57 }
58}
59
60pub type PrintJobFuture =
74 std::pin::Pin<Box<dyn std::future::Future<Output = crate::raster::JobOutcome> + Send>>;
75
76pub type PrintJobFn = Arc<dyn Fn(JobContext, Arc<[u8]>, u32) -> PrintJobFuture + Send + Sync>;
80
81pub type MediaChangeFuture =
83 std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + Send>>;
84
85pub type MediaChangeFn =
96 Arc<dyn Fn(String, crate::device::ReadyMedia) -> MediaChangeFuture + Send + Sync>;
97
98#[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 pub media_change: Option<MediaChangeFn>,
109 pub state_path: std::path::PathBuf,
110 pub advertise_mdns: bool,
119}
120
121#[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
136pub struct Server;
138
139impl Server {
140 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 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 #[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 let _status = crate::status::spawn(
196 opts.device_backend.clone(),
197 opts.printers.clone(),
198 advertiser.clone(),
199 opts.state_path.clone(),
200 );
201 let _advertiser = advertiser;
203
204 axum::serve(listener, Self::router(opts)).await
205 }
206
207 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 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
248fn 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
280fn esc(s: &str) -> String {
283 s.replace('&', "&")
284 .replace('<', "<")
285 .replace('>', ">")
286 .replace('"', """)
287}
288
289#[derive(serde::Deserialize)]
292struct MediaForm {
293 width_mm: u32,
294 height_mm: u32,
295}
296
297fn 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 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 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
481async 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 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 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 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 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 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 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 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 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 if my_jobs_flag(&req) {
729 let user = requesting_user(&req);
730 jobs.retain(|j| j.owner == user);
731 }
732 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 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
784fn operation_attributes_well_ordered(body: &[u8]) -> bool {
790 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; }
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 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
825fn 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
853fn 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
882fn 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
897fn 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
918fn 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
932fn 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
947fn 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
961fn 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
980fn 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 tokio::spawn(async move {
999 {
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 const BACKOFF_MAX: Duration = Duration::from_secs(30);
1025 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 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 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; }
1084 backoff = (backoff * 2).min(BACKOFF_MAX);
1085 }
1086 }
1087 }
1088
1089 if ctx.is_canceled() {
1090 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
1103fn 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
1111fn 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
1119async 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 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 #[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 #[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 #[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 assert_eq!(
1201 printer_name_from_uri("", " Brother HL-2270DW "),
1202 "brother_hl_2270dw"
1203 );
1204 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 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 #[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 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 #[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 #[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 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 tokio::time::sleep(Duration::from_millis(40)).await;
1361 state.jobs.cancel(id);
1362 let _ = cancel; 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 #[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 #[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 #[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 #[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 #[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("<script>"));
1488 }
1489}