Skip to main content

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}