netget/server/ipp/mod.rs
1//! IPP (Internet Printing Protocol) server implementation.
2//!
3//! IPP is HTTP POST with a binary body (RFC 8010/8011); hyper carries the HTTP and this module
4//! parses just enough of the body to name the operation. The LLM supplies the answer as
5//! structured attributes and `actions.rs` encodes it — no action carries bytes.
6//!
7//! There is no job queue and no printer state here: a `Print-Job` is not recorded anywhere, so
8//! a following `Get-Job-Attributes` is answered by the model from its own memory, not from a
9//! store this protocol keeps. That is deliberate.
10//!
11//! **Request parsing is shallow.** Only the 8-byte header is decoded (version, operation-id,
12//! request-id). Attribute groups in the *request* are not parsed, so the model is told which
13//! operation was asked for but not, for instance, which `printer-uri` or `document-format` the
14//! client asked about, nor the document data of a Print-Job. Add attribute-group decoding here
15//! if the model needs to see it.
16//!
17//! That shallowness is also why there is no recursive walk to bound here — the class of defect
18//! that kills the whole process with a `SIGSEGV` no `tokio::spawn` can contain. If request
19//! attribute decoding is ever added, it must stay iterative and must bound each value against
20//! the bytes actually present rather than against the length the peer declared.
21//!
22//! **The body is bounded before it is buffered.** `Incoming` has no default limit, so an
23//! unauthenticated `POST` used to be able to ask this server to buffer whatever it chose to
24//! send. It is read frame by frame against `MAX_IPP_BODY_BYTES` and anything larger is refused
25//! with a well-formed `client-error-request-entity-too-large` and no LLM call.
26//!
27//! **The refusal is then drained, boundedly, before it is sent.** Answering 413 and closing
28//! while the peer is still writing sends `RST`, which discards the response bytes already
29//! written — so the peer's `write` fails and it never reads the refusal at all. See
30//! `LINGER_DRAIN_BYTES`.
31
32pub mod actions;
33
34use std::collections::HashMap;
35use std::convert::Infallible;
36use std::net::SocketAddr;
37use std::sync::Arc;
38
39use bytes::Bytes;
40use http_body_util::{BodyExt, Full};
41use hyper::body::Incoming;
42use hyper::server::conn::http1;
43use hyper::service::service_fn;
44use hyper::{Request, Response};
45use hyper_util::rt::TokioIo;
46use tokio::sync::mpsc;
47use tracing::{debug, error, info, trace};
48
49use crate::llm::ollama_client::OllamaClient;
50use crate::llm::ActionResult;
51use crate::logging::emit::Log;
52use crate::server::connection::ConnectionId;
53use crate::server::IppProtocol;
54use crate::state::app_state::AppState;
55use crate::utils::WireFailure;
56use crate::{console_error, console_info};
57
58use actions::{build_ipp_response, ipp_status_code};
59
60/// How much of a request body is read before the request is refused.
61///
62/// `Incoming` has no default limit, so without this an unauthenticated `POST` decides how much
63/// memory this server allocates. A `Print-Job`'s document data is the only IPP body that is
64/// legitimately large, and this server keeps no job store and never looks past the 8-byte
65/// header, so nothing here needs more than the bound `http_common` uses.
66///
67/// Spelled out rather than borrowed from `http_common::handler::MAX_REQUEST_BODY_BYTES`:
68/// that module is gated behind the `http`/`http2` features and `ipp` does not imply either,
69/// so referencing it would make `--features ipp` alone fail to build.
70pub const MAX_IPP_BODY_BYTES: usize = 8 * 1024 * 1024;
71
72/// How many further octets are read and **discarded** after the cap is exceeded, so the peer
73/// can finish writing and then read the refusal.
74///
75/// A server that answers 413 and closes while the peer is still writing does not deliver its
76/// answer: closing a socket with unread data in the receive queue sends `RST`, which discards
77/// the response bytes already written along with it. The peer's `write` fails with
78/// `ECONNRESET` and it never learns why — so the care taken to express the refusal twice, at
79/// the HTTP layer *and* in the IPP status, is spent on a message nobody receives.
80///
81/// This is nginx's `lingering_close`, and it is bounded for the same reason: draining is
82/// politeness, not an obligation. Nothing is buffered — the octets are counted and dropped —
83/// so the cost is bandwidth and the deadline below, and a peer that keeps writing past either
84/// gets the abrupt close it earned.
85const LINGER_DRAIN_BYTES: usize = 8 * 1024 * 1024;
86
87/// Wall-clock bound on that drain, so a peer trickling one octet at a time cannot hold the
88/// connection open by staying under [`LINGER_DRAIN_BYTES`].
89const LINGER_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
90
91/// Outcome of reading a request body under [`MAX_IPP_BODY_BYTES`].
92enum BodyRead {
93 /// The whole body arrived inside the cap.
94 Complete(Bytes),
95 /// The cap was passed. Never carries the partial body: handing the model the first 8 MiB
96 /// of a longer request is the truncated-body-looks-like-a-complete-one shape this cap
97 /// exists to avoid.
98 TooLarge { drained: usize, whole_body: bool },
99}
100
101/// Read a request body, refusing past the cap and then draining politely.
102///
103/// The read is frame-by-frame rather than `Limited::collect()`, which cannot distinguish
104/// "stop" from "stop and let the peer finish". Once the cap is passed the buffer is dropped
105/// immediately — the refusal is already decided, so holding 8 MiB while draining would double
106/// what an oversized request costs.
107async fn read_body_bounded(body: Incoming) -> BodyRead {
108 let mut body = std::pin::pin!(body);
109 let mut buffered: Vec<u8> = Vec::new();
110 let mut over = false;
111 let mut drained = 0usize;
112 let deadline = tokio::time::Instant::now() + LINGER_DRAIN_TIMEOUT;
113
114 loop {
115 let next = if over {
116 // Only the drain is deadlined. A body inside the cap is hyper's to time out, and
117 // borrowing this deadline for it would cap how long a legitimate 8 MiB Print-Job
118 // may take to arrive.
119 match tokio::time::timeout_at(deadline, body.frame()).await {
120 Ok(next) => next,
121 Err(_) => {
122 return BodyRead::TooLarge {
123 drained,
124 whole_body: false,
125 }
126 }
127 }
128 } else {
129 body.frame().await
130 };
131
132 let Some(frame) = next else {
133 // End of body. Reached while draining, the peer finished writing and can read the
134 // refusal; reached inside the cap, this is the whole request.
135 return if over {
136 BodyRead::TooLarge {
137 drained,
138 whole_body: true,
139 }
140 } else {
141 BodyRead::Complete(Bytes::from(buffered))
142 };
143 };
144
145 let Ok(frame) = frame else {
146 // A transport error mid-body. Answering with what arrived would be answering a
147 // request nobody sent, so this is a refusal either way; `whole_body` is false
148 // because the peer is not in a state to read anything.
149 return BodyRead::TooLarge {
150 drained,
151 whole_body: false,
152 };
153 };
154
155 let Ok(data) = frame.into_data() else {
156 continue; // trailers carry no body octets
157 };
158
159 if over {
160 drained = drained.saturating_add(data.len());
161 if drained >= LINGER_DRAIN_BYTES {
162 return BodyRead::TooLarge {
163 drained,
164 whole_body: false,
165 };
166 }
167 continue;
168 }
169
170 if buffered.len() + data.len() > MAX_IPP_BODY_BYTES {
171 over = true;
172 // Free the partial body now rather than at the end of the drain.
173 buffered = Vec::new();
174 drained = data.len();
175 continue;
176 }
177 buffered.extend_from_slice(&data);
178 }
179}
180
181/// Concurrent connections this server admits before it starts refusing.
182///
183/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. An IPP connection is worth more
184/// than most here — [`MAX_IPP_BODY_BYTES`] is 8 MiB of print job the server will buffer — so
185/// the cap is what turns that per-connection bound into a total one. 256 × 8 MiB is a large
186/// number, but the buffering is transient and per-request while the cap has to stay above any
187/// real spooler's parallelism; a printer front-end that wants a tighter total should be
188/// bounding [`MAX_IPP_BODY_BYTES`], which is the half of the product that actually describes
189/// the memory.
190const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
191
192/// The body of [`CONNECTION_CAP_REFUSAL`].
193const CONNECTION_CAP_BODY: &str = "Too many connections, try again later.\n";
194
195/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
196///
197/// **HTTP's refusal, not IPP's, and that is the honest one.** IPP has a rich status vocabulary
198/// of its own (`server-error-busy`, 0x0507), but every IPP status rides in an
199/// `application/ipp` body that answers a specific operation, echoing the request's own
200/// `request-id` — and a peer over the cap has not sent an operation, so there is no request-id
201/// to echo and nothing to say `server-error-busy` *about*. A fabricated IPP response is the
202/// mis-parse this codebase warns about: CUPS reads the version, operation and request-id back
203/// and reports a protocol error rather than backing off. RFC 8010 §3.1 makes HTTP the
204/// transport, so 503 + `Retry-After` is inside the protocol, and `ipptool`, CUPS and
205/// `pycups` all surface it as a transient HTTP failure.
206static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
207 format!(
208 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
209 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
210 CONNECTION_CAP_BODY.len(),
211 CONNECTION_CAP_BODY
212 )
213 .into_bytes()
214});
215
216/// IPP server that delegates request handling to LLM
217pub struct IppServer;
218
219impl IppServer {
220 /// Spawn the IPP server with integrated LLM actions
221 pub async fn spawn_with_llm_actions(
222 listen_addr: SocketAddr,
223 llm_client: OllamaClient,
224 app_state: Arc<AppState>,
225 status_tx: mpsc::UnboundedSender<String>,
226 _send_first: bool,
227 server_id: crate::state::ServerId,
228 ) -> anyhow::Result<SocketAddr> {
229 let listener =
230 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
231 let local_addr = listener.local_addr()?;
232 console_info!(status_tx, "IPP server listening on {}", local_addr);
233
234 let protocol = Arc::new(IppProtocol::new());
235
236 // Spawn server loop
237 let task_registrar = app_state.clone();
238 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
239 let accept_handle = tokio::spawn(async move {
240 loop {
241 match crate::server::accept_bounded::accept_bounded(
242 &listener,
243 &limiter,
244 &CONNECTION_CAP_REFUSAL,
245 "IPP",
246 Some(&status_tx),
247 )
248 .await
249 {
250 Ok((stream, remote_addr, permit)) => {
251 let connection_id =
252 ConnectionId::new(app_state.get_next_unified_id().await);
253 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
254 info!("IPP connection {} from {}", connection_id, remote_addr);
255 Log::new(Some(&status_tx))
256 .info(format!("IPP connection from {}", remote_addr));
257
258 // Add connection to ServerInstance
259 use crate::state::server::{
260 ConnectionState as ServerConnectionState, ConnectionStatus,
261 ProtocolConnectionInfo,
262 };
263 let now = crate::utils::clock::Instant::now();
264 let conn_state = ServerConnectionState {
265 id: connection_id,
266 remote_addr,
267 local_addr: local_addr_conn,
268 bytes_sent: 0,
269 bytes_received: 0,
270 packets_sent: 0,
271 packets_received: 0,
272 last_activity: now,
273 status: ConnectionStatus::Active,
274 status_changed_at: now,
275 protocol_info: ProtocolConnectionInfo::empty(),
276 };
277 app_state
278 .add_connection_to_server(server_id, conn_state)
279 .await;
280 let _ = status_tx.send("__UPDATE_UI__".to_string());
281
282 let llm_client_clone = llm_client.clone();
283 let app_state_clone = app_state.clone();
284 let status_tx_clone = status_tx.clone();
285 let protocol_clone = protocol.clone();
286
287 // Spawn a task to handle this connection
288 // Tracked, not detached: stop_server must abort this task too.
289 let task_owner = app_state.clone();
290 task_owner
291 .spawn_server_task(server_id, async move {
292 // Released when this task ends. hyper's `serve_connection` is
293 // awaited to completion inside it and IPP spawns nothing else
294 // per peer, so the slot is held for the whole connection —
295 // including a keep-alive one serving several operations.
296 let _permit = permit;
297 let io = TokioIo::new(stream);
298
299 // Clone for service closure
300 let status_for_service = status_tx_clone.clone();
301 let app_state_for_service = app_state_clone.clone();
302
303 // Create a service that handles IPP requests with LLM
304 let service = service_fn(move |req: Request<Incoming>| {
305 let llm_clone = llm_client_clone.clone();
306 let state_clone = app_state_for_service.clone();
307 let status_clone = status_for_service.clone();
308 let protocol_clone = protocol_clone.clone();
309 handle_ipp_request_with_llm(
310 req,
311 connection_id,
312 llm_clone,
313 state_clone,
314 status_clone,
315 protocol_clone,
316 server_id,
317 )
318 });
319
320 // Serve HTTP/1 on this connection (IPP uses HTTP)
321 if let Err(err) =
322 http1::Builder::new().serve_connection(io, service).await
323 {
324 error!("Error serving IPP connection: {:?}", err);
325 }
326
327 // Mark connection as closed
328 app_state_clone
329 .close_connection_on_server(server_id, connection_id)
330 .await;
331 Log::new(Some(&status_tx_clone))
332 .info(format!("IPP connection {} closed", connection_id));
333 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
334 })
335 .await;
336 }
337 Err(e) => {
338 console_error!(status_tx, "Failed to accept IPP connection: {}", e);
339 break;
340 }
341 }
342 }
343 });
344
345 task_registrar
346 .register_server_task(server_id, accept_handle)
347 .await;
348
349 Ok(local_addr)
350 }
351}
352
353/// Handle a single IPP request with LLM
354async fn handle_ipp_request_with_llm(
355 req: Request<Incoming>,
356 connection_id: ConnectionId,
357 llm_client: OllamaClient,
358 app_state: Arc<AppState>,
359 status_tx: mpsc::UnboundedSender<String>,
360 protocol: Arc<IppProtocol>,
361 server_id: crate::state::ServerId,
362) -> Result<Response<Full<Bytes>>, Infallible> {
363 // Extract request details
364 let method = req.method().to_string();
365 let uri = req.uri().to_string();
366
367 // Extract headers
368 let mut headers = HashMap::new();
369 for (name, value) in req.headers() {
370 if let Ok(value_str) = value.to_str() {
371 headers.insert(name.to_string(), value_str.to_string());
372 }
373 }
374
375 // Read body (IPP operation data), bounded. `Limited` errors as soon as the cap is passed
376 // rather than after buffering the whole thing, so an oversized request costs at most the
377 // cap. Refusing here also means an oversized request never reaches the model.
378 //
379 // A read failure is NOT treated as an empty body: `Bytes::new()` would be handed to the
380 // model as the operation `Empty`, so it would answer a request it never saw. IPP has a
381 // status for exactly this case and it is used.
382 let body_bytes = match read_body_bounded(req.into_body()).await {
383 BodyRead::Complete(bytes) => bytes,
384 BodyRead::TooLarge {
385 drained,
386 whole_body,
387 } => {
388 console_error!(
389 status_tx,
390 "IPP {} refusing request body; limit is {} bytes",
391 connection_id,
392 MAX_IPP_BODY_BYTES
393 );
394 Log::new(Some(&status_tx)).warn(format!(
395 "IPP {} decision=fail_closed_body_too_large (limit {} bytes, drained {} more, \
396 peer finished writing: {})",
397 connection_id, MAX_IPP_BODY_BYTES, drained, whole_body
398 ));
399 // 413 at the HTTP layer and the matching IPP status in the body: a client that
400 // reads either one learns the same thing. request-id is unknown - the header was
401 // never read - so it stays 0.
402 let body = build_ipp_response(
403 ipp_status_code("client-error-request-entity-too-large"),
404 Some("request body exceeds this server's limit"),
405 None,
406 );
407 return Ok(ipp_response_counted(&app_state, server_id, connection_id, 413, body).await);
408 }
409 };
410
411 app_state
412 .update_connection_stats(
413 server_id,
414 connection_id,
415 Some(body_bytes.len() as u64),
416 None,
417 Some(1),
418 None,
419 )
420 .await;
421
422 Log::new(Some(&status_tx)).debug(format!(
423 "IPP {} {} ({} bytes)",
424 method,
425 uri,
426 body_bytes.len()
427 ));
428
429 // Parse the IPP header. The request-id must be echoed in the response or clients discard
430 // it as unmatched, so it is parsed here and stamped in below - the model is never asked
431 // for it and cannot get it wrong.
432 let header = parse_ipp_header(&body_bytes);
433 let operation_name = header
434 .as_ref()
435 .map(|h| h.operation.clone())
436 .unwrap_or_else(|| {
437 if body_bytes.is_empty() {
438 "Empty".to_string()
439 } else {
440 "Malformed".to_string()
441 }
442 });
443 let request_id = header.as_ref().map(|h| h.request_id).unwrap_or(0);
444 let ipp_version = header
445 .as_ref()
446 .map(|h| format!("{}.{}", h.version_major, h.version_minor))
447 .unwrap_or_else(|| "unknown".to_string());
448
449 trace!(
450 "IPP operation: {} (request-id {}, v{})",
451 operation_name,
452 request_id,
453 ipp_version
454 );
455
456 // Create IPP request event
457 let event = crate::protocol::Event::new(
458 &actions::IPP_REQUEST_EVENT,
459 serde_json::json!({
460 "method": method,
461 "uri": uri,
462 "operation": operation_name,
463 "request_id": request_id,
464 "ipp_version": ipp_version,
465 "answer_with": actions::answer_with_for_operation(&operation_name),
466 }),
467 );
468
469 let llm_result = crate::llm::action_helper::call_llm(
470 &llm_client,
471 &app_state,
472 server_id,
473 // Connection-scoped event handlers and connection-scoped scheduled tasks both key on
474 // this. Passing `None` here (it was a TODO, and the id was in scope the whole time)
475 // made both silently inapplicable to every IPP request.
476 Some(connection_id),
477 &event,
478 protocol.as_ref(),
479 )
480 .await;
481
482 // Process action results to build HTTP response
483 match llm_result {
484 Ok(execution_result) => {
485 // Look for IPP-specific response actions
486 for result in execution_result.protocol_results {
487 if let ActionResult::Custom { name, data } = result {
488 if name == "ipp_response" {
489 // `http_status` was range-checked in the executor before it was put
490 // here, so this is `as u16` on a value already proven to be 100..=599.
491 let status = data
492 .get("http_status")
493 .and_then(|v| v.as_u64())
494 .unwrap_or(200) as u16;
495 let body_hex = data.get("body_hex").and_then(|v| v.as_str()).unwrap_or("");
496
497 // `unwrap_or_default()` here used to turn a decode failure into an
498 // EMPTY body, which is not a valid IPP message: the client reports a
499 // truncated response, exactly the failure the no-action branch below
500 // exists to avoid. The hex is written by `ipp_wire_response` and read
501 // here, so a failure means those two have drifted - loud, and answered
502 // with something parseable.
503 let Ok(mut body) = hex::decode(body_hex) else {
504 error!(
505 "IPP {} could not decode its own encoded response body \
506 ({} chars); answering server-error-internal-error",
507 connection_id,
508 body_hex.len()
509 );
510 Log::new(Some(&status_tx)).warn(format!(
511 "IPP {} decision=fail_closed_encoding_bug",
512 connection_id
513 ));
514 return Ok(ipp_response_counted(
515 &app_state,
516 server_id,
517 connection_id,
518 200,
519 internal_error_body(header.as_ref(), request_id),
520 )
521 .await);
522 };
523
524 stamp_response_header(&mut body, header.as_ref(), request_id);
525
526 debug!(
527 "IPP {} decision=model_answer http={} request-id={} ({} bytes)",
528 connection_id,
529 status,
530 request_id,
531 body.len()
532 );
533 Log::new(Some(&status_tx)).debug(format!("IPP → {} response", status));
534
535 return Ok(ipp_response_counted(
536 &app_state,
537 server_id,
538 connection_id,
539 status,
540 body,
541 )
542 .await);
543 }
544 }
545 // Other actions don't affect HTTP response
546 }
547
548 // The LLM produced no IPP response action. An empty 200 is not a valid IPP message
549 // and clients report it as a truncated response, so send a well-formed
550 // server-error-internal-error instead of a body the client cannot parse.
551 //
552 // The `decision=` tag is the only place the distinction survives: on the wire this
553 // is byte-identical to the backend-failure answer below, and to a model that chose
554 // `ipp_status: "server-error-internal-error"` on purpose.
555 let tag = if execution_result.failures.is_empty() {
556 "model_silent"
557 } else {
558 "fail_closed_action_error"
559 };
560 debug!(
561 "IPP {} decision={} - no ipp_* response action, \
562 answering server-error-internal-error",
563 connection_id, tag
564 );
565 Log::new(Some(&status_tx)).warn(format!(
566 "IPP {} decision={}: no ipp_* response action, sending \
567 server-error-internal-error",
568 connection_id, tag
569 ));
570 Ok(ipp_response_counted(
571 &app_state,
572 server_id,
573 connection_id,
574 200,
575 internal_error_body(header.as_ref(), request_id),
576 )
577 .await)
578 }
579 Err(e) => {
580 // The peer gets a category, the log gets the error. IPP has no free-text field in
581 // which a category string would be honest, so the category picks the *status*: a
582 // saturated backend is transient and says so with `server-error-busy`, which is
583 // what a client retries on, while anything else is `server-error-internal-error`.
584 let failure = WireFailure::classify(&e);
585 console_error!(
586 status_tx,
587 "IPP {} decision=fail_closed_llm_error: {}",
588 connection_id,
589 e
590 );
591 let (status_name, message) = match failure {
592 WireFailure::Overloaded => (
593 "server-error-busy",
594 "netget: backend at capacity, retry later",
595 ),
596 WireFailure::Unavailable => (
597 "server-error-internal-error",
598 "netget: request could not be processed",
599 ),
600 };
601 let mut body = build_ipp_response(ipp_status_code(status_name), Some(message), None);
602 stamp_response_header(&mut body, header.as_ref(), request_id);
603 // 200 with an IPP-level error, not an HTTP 500 with a text body: an IPP client
604 // shown "Internal Server Error" reports a protocol error with no indication of
605 // what went wrong.
606 Ok(ipp_response_counted(&app_state, server_id, connection_id, 200, body).await)
607 }
608 }
609}
610
611/// Build the HTTP envelope and record the response's bytes against the connection.
612///
613/// Nothing recorded them, so an IPP server's `up` counter in the dashboard rail stayed at zero
614/// for its whole life however much it printed. Building and counting in one call is what keeps
615/// a future exit path from forgetting one of the two.
616async fn ipp_response_counted(
617 app_state: &Arc<AppState>,
618 server_id: crate::state::ServerId,
619 connection_id: ConnectionId,
620 status: u16,
621 body: Vec<u8>,
622) -> Response<Full<Bytes>> {
623 let sent = body.len() as u64;
624 app_state
625 .update_connection_stats(server_id, connection_id, None, Some(sent), None, Some(1))
626 .await;
627 ipp_http_response(status, body)
628}
629
630/// Build the HTTP envelope IPP requires.
631fn ipp_http_response(status: u16, body: Vec<u8>) -> Response<Full<Bytes>> {
632 Response::builder()
633 .status(status)
634 .header("Content-Type", "application/ipp")
635 .body(Full::new(Bytes::from(body)))
636 // Only fails on an invalid status code, and `status` comes from a u16 we control or
637 // clamp; fall back to a bare 500 rather than panicking in a connection task.
638 .unwrap_or_else(|_| {
639 Response::builder()
640 .status(500)
641 .body(Full::new(Bytes::new()))
642 .expect("500 with an empty body is always constructible")
643 })
644}
645
646/// A minimal, well-formed `server-error-internal-error` message.
647fn internal_error_body(header: Option<&IppHeader>, request_id: u32) -> Vec<u8> {
648 let mut body = vec![
649 0x02, 0x00, // version 2.0
650 0x05, 0x00, // server-error-internal-error
651 0x00, 0x00, 0x00, 0x00, // request-id, stamped below
652 0x01, // operation-attributes-tag
653 0x47, // charset
654 ];
655 body.extend_from_slice(&[0x00, 0x12]);
656 body.extend_from_slice(b"attributes-charset");
657 body.extend_from_slice(&[0x00, 0x05]);
658 body.extend_from_slice(b"utf-8");
659 body.push(0x48); // naturalLanguage
660 body.extend_from_slice(&[0x00, 0x1b]);
661 body.extend_from_slice(b"attributes-natural-language");
662 body.extend_from_slice(&[0x00, 0x05]);
663 body.extend_from_slice(b"en-us");
664 body.push(0x03); // end-of-attributes-tag
665 stamp_response_header(&mut body, header, request_id);
666 body
667}
668
669/// Write the request's own version and id into an encoded response.
670///
671/// Both are echoed by the server rather than asked of the model, so correctness cannot depend
672/// on it repeating a number back — the same class of bug that made DNS and NTP responses go
673/// unmatched by their clients.
674///
675/// - **request-id**: RFC 8011 requires the response's to equal the request's.
676/// - **version**: RFC 8011 §4.1.8 requires the response to carry the version the client sent.
677/// The encoders write 2.0; `ipptool`, which speaks 1.1 by default, failed every response
678/// with "Bad version 2.0 in response - expected 1.1" until this echoed it back.
679fn stamp_response_header(body: &mut [u8], header: Option<&IppHeader>, request_id: u32) {
680 use actions::REQUEST_ID_OFFSET;
681
682 if let (Some(header), true) = (header, body.len() >= 2) {
683 body[0] = header.version_major;
684 body[1] = header.version_minor;
685 }
686 if body.len() >= REQUEST_ID_OFFSET + 4 {
687 body[REQUEST_ID_OFFSET..REQUEST_ID_OFFSET + 4].copy_from_slice(&request_id.to_be_bytes());
688 }
689}
690
691/// The fixed 8-byte IPP message header.
692struct IppHeader {
693 version_major: u8,
694 version_minor: u8,
695 operation: String,
696 request_id: u32,
697}
698
699/// Parse the IPP header: version(2) + operation-id(2) + request-id(4).
700///
701/// Every index below is covered by the length check; there is no slicing past it.
702fn parse_ipp_header(body: &[u8]) -> Option<IppHeader> {
703 if body.len() < 8 {
704 return None;
705 }
706
707 let operation_id = u16::from_be_bytes([body[2], body[3]]);
708 let request_id = u32::from_be_bytes([body[4], body[5], body[6], body[7]]);
709
710 Some(IppHeader {
711 version_major: body[0],
712 version_minor: body[1],
713 operation: ipp_operation_name(operation_id),
714 request_id,
715 })
716}
717
718/// Map an IPP operation id to its name.
719fn ipp_operation_name(operation_id: u16) -> String {
720 let name = match operation_id {
721 0x0002 => "Print-Job",
722 0x0003 => "Print-URI",
723 0x0004 => "Validate-Job",
724 0x0005 => "Create-Job",
725 0x0006 => "Send-Document",
726 0x0007 => "Send-URI",
727 0x0008 => "Cancel-Job",
728 0x0009 => "Get-Job-Attributes",
729 0x000A => "Get-Jobs",
730 0x000B => "Get-Printer-Attributes",
731 0x000C => "Hold-Job",
732 0x000D => "Release-Job",
733 0x000E => "Restart-Job",
734 0x000F => "Pause-Printer",
735 0x0010 => "Resume-Printer",
736 0x0011 => "Purge-Jobs",
737 0x0012 => "Set-Printer-Attributes",
738 0x0013 => "Set-Job-Attributes",
739 0x003B => "Close-Job",
740 0x003C => "Identify-Printer",
741 0x003D => "Validate-Document",
742 _ => return format!("Operation-0x{:04X}", operation_id),
743 };
744
745 name.to_string()
746}