Skip to main content

netget/server/ipp/
actions.rs

1//! IPP protocol actions implementation.
2//!
3//! The LLM answers IPP operations with **structured** actions — an IPP status name and an
4//! attribute object — and this file encodes them into the IPP binary format (RFC 8010). No
5//! action carries raw bytes, hex or base64: the previous `ipp_response.body` parameter, which
6//! the docs described as "hex-encoded IPP response data", was passed straight through
7//! `hex::encode(body.as_bytes())`, so a model following the documentation put the ASCII text
8//! `"0200000000000001..."` on the wire as the response body. It is gone.
9//!
10//! There is no print queue and no job store here, deliberately: the protocol implements no
11//! storage, so job IDs, job states and printer attributes all come from the model.
12
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, Parameter,
16};
17use crate::protocol::log_template::LogTemplate;
18use crate::protocol::EventType;
19use crate::state::app_state::AppState;
20use anyhow::{Context, Result};
21use serde_json::json;
22use std::sync::LazyLock;
23use tracing::debug;
24
25/// IPP protocol action handler
26pub struct IppProtocol {}
27
28impl IppProtocol {
29    pub fn new() -> Self {
30        Self {}
31    }
32}
33
34impl Default for IppProtocol {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40// Implement Protocol trait (common functionality)
41impl Protocol for IppProtocol {
42    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
43        vec![crate::llm::actions::ParameterDefinition {
44            name: "send_first".to_string(),
45            type_hint: "boolean".to_string(),
46            description: "Unsupported by this server and refused if set to true. IPP is \
47                         strictly request/response over HTTP, so the server cannot speak \
48                         before a request arrives."
49                .to_string(),
50            required: false,
51            example: serde_json::json!(false),
52            default: None,
53        }]
54    }
55
56    /// No async actions.
57    ///
58    /// `list_print_jobs` used to be declared here. It returned a hardcoded `{"jobs": []}` with
59    /// a `// This is a placeholder` comment, because there is no job store to list — and there
60    /// must not be one: the protocol implements no storage, the model owns job state. Removed.
61    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
62        Vec::new()
63    }
64
65    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
66        ipp_response_actions()
67    }
68    fn protocol_name(&self) -> &'static str {
69        "IPP"
70    }
71    fn get_event_types(&self) -> Vec<EventType> {
72        get_ipp_event_types()
73    }
74    fn stack_name(&self) -> &'static str {
75        "ETH>IP>TCP>HTTP>IPP"
76    }
77    fn keywords(&self) -> Vec<&'static str> {
78        vec!["ipp", "printer", "print"]
79    }
80    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
81        use crate::protocol::metadata::{
82            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
83        };
84
85        ProtocolMetadataV2::builder()
86            .state(DevelopmentState::Experimental)
87            // 631 is below 1024 and is the port every IPP client tries by default, so the
88            // preflight check in server_startup.rs should fire rather than letting the bind
89            // fail with a bare EPERM.
90            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(631))
91            .well_known_port(631)
92            .implementation("hyper HTTP/1 + manual IPP (RFC 8010) attribute encoding")
93            .llm_control("IPP status code, printer attributes, job attributes, HTTP status")
94            .e2e_testing("ipptool / curl --data-binary, and tests/server/ipp/test.rs")
95            .notes(
96                "Responses are built from structured attributes, never from model-supplied \
97                 bytes. The request-id is echoed by the server, not by the model. No job \
98                 store: job IDs and states come from the model. Requests are parsed only far \
99                 enough to name the operation - request attributes are not decoded, so the \
100                 model is told which operation was asked for but not what it asked for. No \
101                 IPPS, no authentication, no CUPS extensions.",
102            )
103            .max_inbound_bytes(crate::server::ipp::MAX_IPP_BODY_BYTES)
104            .request_only(
105                "IPP is HTTP request/response; a server cannot send a peer anything unprompted",
106            )
107            .build()
108    }
109    fn description(&self) -> &'static str {
110        "Internet Printing Protocol server"
111    }
112    fn example_prompt(&self) -> &'static str {
113        "Start an IPP server on port 631"
114    }
115    fn group_name(&self) -> &'static str {
116        "Web & File"
117    }
118
119    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
120        use crate::llm::actions::StartupExamples;
121        use serde_json::json;
122
123        // Deterministic: report a fixed idle printer for every IPP request, no
124        // LLM call.
125        let script = r#"import json, sys
126data = json.load(sys.stdin)
127event = data["event"]
128if data["event_type_id"] == "ipp_request_received":
129    actions = [{"type": "ipp_printer_attributes",
130                "attributes": {"printer-name": "NetGet Printer",
131                               "printer-state": "idle",
132                               "printer-is-accepting-jobs": True}}]
133else:
134    actions = []
135print(json.dumps({"actions": actions}))"#;
136
137        StartupExamples::new(
138            // LLM mode: LLM handles all IPP responses intelligently
139            json!({
140                "type": "open_server",
141                "port": 631,
142                "base_stack": "ipp",
143                "instruction": "IPP printer server. Answer Get-Printer-Attributes with printer-name 'NetGet Printer', printer-state 'idle'. Accept print jobs, assigning increasing job ids."
144            }),
145            // Script mode: Code-based deterministic responses
146            json!({
147                "type": "open_server",
148                "port": 631,
149                "base_stack": "ipp",
150                "event_handlers": [{
151                    "event_pattern": "ipp_request_received",
152                    "handler": {
153                        "type": "script",
154                        "language": "python",
155                        "code": script
156                    }
157                }]
158            }),
159            // Static mode: Fixed responses
160            json!({
161                "type": "open_server",
162                "port": 631,
163                "base_stack": "ipp",
164                "event_handlers": [{
165                    "event_pattern": "ipp_request_received",
166                    "handler": {
167                        "type": "static",
168                        "actions": [{
169                            "type": "ipp_printer_attributes",
170                            "attributes": {
171                                "printer-name": "NetGet Printer",
172                                "printer-state": "idle",
173                                "printer-is-accepting-jobs": true
174                            }
175                        }]
176                    }
177                }]
178            }),
179        )
180    }
181}
182
183// Implement Server trait (server-specific functionality)
184impl Server for IppProtocol {
185    fn spawn(
186        &self,
187        ctx: crate::protocol::SpawnContext,
188    ) -> std::pin::Pin<
189        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
190    > {
191        Box::pin(async move {
192            use crate::server::ipp::IppServer;
193            // `send_first` stays declared so a caller that passes `false` still validates - an
194            // undeclared key is refused outright - but `true` is refused rather than ignored.
195            // It was read here, threaded through spawn, and dropped at the far end as
196            // `_send_first`: a knob advertised to the model, plumbed through three hops, and
197            // silently doing nothing. IPP: IPP is request/response over HTTP, where the server cannot speak before a request arrives.
198            let send_first = ctx
199                .startup_params
200                .as_ref()
201                .map(|p| p.get_optional_bool("send_first"))
202                .transpose()?
203                .flatten()
204                .unwrap_or(false);
205            if send_first {
206                return Err(anyhow::anyhow!(
207                    "send_first is not supported by the IPP server: IPP is request/response over HTTP, where the server cannot speak before a request arrives"
208                ));
209            }
210
211            IppServer::spawn_with_llm_actions(
212                ctx.legacy_listen_addr(),
213                ctx.llm_client,
214                ctx.state,
215                ctx.status_tx,
216                send_first,
217                ctx.server_id,
218            )
219            .await
220        })
221    }
222    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
223        let action_type = action
224            .get("type")
225            .and_then(|v| v.as_str())
226            .context("Missing 'type' field in action")?;
227
228        match action_type {
229            "ipp_response" => self.execute_ipp_response(action),
230            "ipp_printer_attributes" => self.execute_ipp_printer_attributes(action),
231            "ipp_job_attributes" => self.execute_ipp_job_attributes(action),
232            _ => Err(anyhow::anyhow!("Unknown IPP action: {}", action_type)),
233        }
234    }
235}
236
237impl IppProtocol {
238    fn execute_ipp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
239        // `http_status` is the documented name; `status` is accepted because that is what the
240        // action was originally called and prompts in the wild still use it.
241        // Range-checked, not narrowed. `as u16` on a `u64` wraps in silence, so an
242        // `http_status` of 65736 became 200 — the model's refusal delivered as the success it
243        // was refusing. Refusing beats clamping: 599 is not what the model asked for either.
244        let http_status = match action.get("http_status").or_else(|| action.get("status")) {
245            None => 200,
246            Some(v) if v.is_null() => 200,
247            Some(v) => {
248                let raw = v.as_u64().with_context(|| {
249                    format!("send_ipp_response 'http_status' must be a number, got {v}")
250                })?;
251                if !(100..=599).contains(&raw) {
252                    anyhow::bail!(
253                        "send_ipp_response 'http_status' {raw} is not an HTTP status code; \
254                         use 100-599. IPP carries its own outcome in 'ipp_status' and answers \
255                         200 even for an IPP-level error."
256                    );
257                }
258                raw as u16
259            }
260        };
261
262        let ipp_status_name = action
263            .get("ipp_status")
264            .and_then(|v| v.as_str())
265            .unwrap_or("successful-ok");
266        let ipp_status = ipp_status_code(ipp_status_name);
267
268        let status_message = action.get("status_message").and_then(|v| v.as_str());
269
270        debug!(
271            "IPP response: http={} ipp_status={} (0x{:04x})",
272            http_status, ipp_status_name, ipp_status
273        );
274
275        let body = build_ipp_response(ipp_status, status_message, None);
276        Ok(ipp_wire_response(http_status, body))
277    }
278
279    fn execute_ipp_printer_attributes(&self, action: serde_json::Value) -> Result<ActionResult> {
280        let attributes = action
281            .get("attributes")
282            .and_then(|v| v.as_object())
283            .context("Missing 'attributes' object")?;
284        validate_attributes(attributes)?;
285
286        debug!("IPP printer attributes: {} attrs", attributes.len());
287
288        let ipp_status = ipp_status_code(
289            action
290                .get("ipp_status")
291                .and_then(|v| v.as_str())
292                .unwrap_or("successful-ok"),
293        );
294
295        let body = build_ipp_response(ipp_status, None, Some((PRINTER_ATTRIBUTES_TAG, attributes)));
296        Ok(ipp_wire_response(200, body))
297    }
298
299    fn execute_ipp_job_attributes(&self, action: serde_json::Value) -> Result<ActionResult> {
300        let attributes = action
301            .get("attributes")
302            .and_then(|v| v.as_object())
303            .context("Missing 'attributes' object")?;
304        validate_attributes(attributes)?;
305
306        debug!("IPP job attributes: {} attrs", attributes.len());
307
308        let ipp_status = ipp_status_code(
309            action
310                .get("ipp_status")
311                .and_then(|v| v.as_str())
312                .unwrap_or("successful-ok"),
313        );
314
315        let body = build_ipp_response(ipp_status, None, Some((JOB_ATTRIBUTES_TAG, attributes)));
316        Ok(ipp_wire_response(200, body))
317    }
318}
319
320/// Package an encoded IPP body for `handle_ipp_request_with_llm`.
321///
322/// The hex here is transport between the executor and the HTTP handler, never something the
323/// model writes or reads — `ActionResult::Custom` carries `serde_json::Value`, which has no
324/// byte type. The handler decodes it and stamps the real request-id into it.
325fn ipp_wire_response(http_status: u16, body: Vec<u8>) -> ActionResult {
326    ActionResult::Custom {
327        name: "ipp_response".to_string(),
328        data: json!({
329            "http_status": http_status,
330            "body_hex": hex::encode(&body),
331        }),
332    }
333}
334
335fn ipp_response_actions() -> Vec<ActionDefinition> {
336    vec![
337        ipp_response_action(),
338        ipp_printer_attributes_action(),
339        ipp_job_attributes_action(),
340    ]
341}
342
343/// Action definition: Send a bare IPP response (status only, no attribute group)
344pub fn ipp_response_action() -> ActionDefinition {
345    ActionDefinition {
346        name: "ipp_response".to_string(),
347        description: "Answer an IPP operation with a status code and no attributes: for \
348                      operations that return nothing but a status, and for refusing one. \
349                      Never for Get-Printer-Attributes or a job operation that is being \
350                      answered - ipp_printer_attributes / ipp_job_attributes carry their own \
351                      status, and a status-only reply sent beside them is the one the client \
352                      gets. One response action per request."
353            .to_string(),
354        parameters: vec![
355            Parameter {
356                name: "ipp_status".to_string(),
357                type_hint: "string".to_string(),
358                description: "IPP status name: successful-ok (default), client-error-bad-request, \
359                              client-error-not-found, client-error-not-authorized, \
360                              client-error-document-format-not-supported, \
361                              server-error-not-accepting-jobs, server-error-internal-error, \
362                              server-error-busy"
363                    .to_string(),
364                required: false,
365            },
366            Parameter {
367                name: "status_message".to_string(),
368                type_hint: "string".to_string(),
369                description: "Human-readable explanation returned as the status-message attribute"
370                    .to_string(),
371                required: false,
372            },
373            Parameter {
374                name: "http_status".to_string(),
375                type_hint: "number".to_string(),
376                description: "HTTP status code (default 200). IPP errors belong in ipp_status; \
377                              use this only for HTTP-level failures such as 401 or 404."
378                    .to_string(),
379                required: false,
380            },
381        ],
382        example: json!({
383            "type": "ipp_response",
384            "ipp_status": "successful-ok"
385        }),
386        log_template: Some(
387            LogTemplate::new()
388                .with_info("-> IPP {ipp_status}")
389                .with_debug("IPP ipp_response: ipp_status={ipp_status} http_status={http_status}"),
390        ),
391    }
392}
393
394/// Action definition: Send printer attributes response
395pub fn ipp_printer_attributes_action() -> ActionDefinition {
396    ActionDefinition {
397        name: "ipp_printer_attributes".to_string(),
398        description: "The answer to Get-Printer-Attributes, sent alone - it carries its own \
399                      status, so no ipp_response goes with it. Always the answer to that \
400                      operation, whatever condition the printer is in: a stopped printer, or \
401                      one refusing jobs, is still described here with ipp_status \
402                      successful-ok - printer-state 'stopped', printer-is-accepting-jobs \
403                      false - because the client asked what the printer is, not to print. \
404                      Put the printer's name in printer-name."
405            .to_string(),
406        parameters: vec![
407            Parameter {
408                name: "attributes".to_string(),
409                type_hint: "object".to_string(),
410                description: "Printer attributes by name. Strings, numbers, booleans and arrays \
411                              of them are all accepted and encoded with the right IPP value \
412                              tag. printer-state takes 'idle', 'processing' or 'stopped'. \
413                              Example: {\"printer-name\": \"My Printer\", \"printer-state\": \
414                              \"idle\", \"printer-is-accepting-jobs\": true, \
415                              \"printer-uri-supported\": [\"ipp://localhost:631/printers/p1\"]}"
416                    .to_string(),
417                required: true,
418            },
419            Parameter {
420                name: "ipp_status".to_string(),
421                type_hint: "string".to_string(),
422                description: "IPP status name (default successful-ok)".to_string(),
423                required: false,
424            },
425        ],
426        example: json!({
427            "type": "ipp_printer_attributes",
428            "attributes": {
429                "printer-name": "My Printer",
430                "printer-state": "idle",
431                "printer-is-accepting-jobs": true,
432                "printer-uri-supported": ["ipp://localhost:631/printers/my-printer"],
433                "document-format-supported": ["application/pdf", "text/plain"]
434            }
435        }),
436        log_template: Some(
437            LogTemplate::new()
438                .with_info("-> IPP printer attributes")
439                .with_debug("IPP ipp_printer_attributes: {attributes_len} attrs"),
440        ),
441    }
442}
443
444/// Action definition: Send job attributes response
445pub fn ipp_job_attributes_action() -> ActionDefinition {
446    ActionDefinition {
447        name: "ipp_job_attributes".to_string(),
448        description: "Answer a job operation (Print-Job, Get-Job-Attributes, Create-Job) with a \
449                      job attribute group"
450            .to_string(),
451        parameters: vec![
452            Parameter {
453                name: "attributes".to_string(),
454                type_hint: "object".to_string(),
455                description: "Job attributes by name. job-state takes 'pending', 'held', \
456                              'processing', 'stopped', 'canceled', 'aborted' or 'completed'. \
457                              Example: {\"job-id\": 123, \"job-state\": \"completed\", \
458                              \"job-name\": \"document.pdf\"}"
459                    .to_string(),
460                required: true,
461            },
462            Parameter {
463                name: "ipp_status".to_string(),
464                type_hint: "string".to_string(),
465                description: "IPP status name (default successful-ok)".to_string(),
466                required: false,
467            },
468        ],
469        example: json!({
470            "type": "ipp_job_attributes",
471            "attributes": {
472                "job-id": 123,
473                "job-uri": "ipp://localhost:631/jobs/123",
474                "job-state": "completed",
475                "job-name": "document.pdf"
476            }
477        }),
478        log_template: Some(
479            LogTemplate::new()
480                .with_info("-> IPP job attributes")
481                .with_debug("IPP ipp_job_attributes: {attributes_len} attrs"),
482        ),
483    }
484}
485
486// ============================================================================
487// IPP binary encoding (RFC 8010)
488// ============================================================================
489
490/// Delimiter tag opening the operation-attributes group.
491const OPERATION_ATTRIBUTES_TAG: u8 = 0x01;
492/// Delimiter tag opening the job-attributes group.
493const JOB_ATTRIBUTES_TAG: u8 = 0x02;
494/// Delimiter tag opening the printer-attributes group.
495const PRINTER_ATTRIBUTES_TAG: u8 = 0x04;
496/// Delimiter tag ending all attribute groups.
497const END_OF_ATTRIBUTES_TAG: u8 = 0x03;
498
499// Value tags (RFC 8010 section 3.5.2).
500const TAG_INTEGER: u8 = 0x21;
501const TAG_BOOLEAN: u8 = 0x22;
502const TAG_ENUM: u8 = 0x23;
503const TAG_TEXT: u8 = 0x41;
504const TAG_NAME: u8 = 0x42;
505const TAG_KEYWORD: u8 = 0x44;
506const TAG_URI: u8 = 0x45;
507const TAG_CHARSET: u8 = 0x47;
508const TAG_NATURAL_LANGUAGE: u8 = 0x48;
509const TAG_MIME_MEDIA_TYPE: u8 = 0x49;
510
511/// Byte offset of the request-id field in an IPP message.
512///
513/// `handle_ipp_request_with_llm` overwrites these four bytes with the request's own id before
514/// sending, so a response always correlates even though no action carries a request-id.
515pub const REQUEST_ID_OFFSET: usize = 4;
516
517/// Map an IPP status name to its wire code (RFC 8011 appendix B).
518///
519/// Unknown names become `server-error-internal-error` rather than `successful-ok`: telling a
520/// client everything is fine because the model invented a status name is the worse failure.
521pub(crate) fn ipp_status_code(name: &str) -> u16 {
522    match name {
523        "successful-ok" => 0x0000,
524        "successful-ok-ignored-or-substituted-attributes" => 0x0001,
525        "successful-ok-conflicting-attributes" => 0x0002,
526        "client-error-bad-request" => 0x0400,
527        "client-error-forbidden" => 0x0401,
528        "client-error-not-authenticated" => 0x0402,
529        "client-error-not-authorized" => 0x0403,
530        "client-error-not-possible" => 0x0404,
531        "client-error-timeout" => 0x0405,
532        "client-error-not-found" => 0x0406,
533        "client-error-gone" => 0x0407,
534        "client-error-request-entity-too-large" => 0x0408,
535        "client-error-request-value-too-long" => 0x0409,
536        "client-error-document-format-not-supported" => 0x040A,
537        "client-error-attributes-or-values-not-supported" => 0x040B,
538        "client-error-uri-scheme-not-supported" => 0x040C,
539        "client-error-charset-not-supported" => 0x040D,
540        "client-error-conflicting-attributes" => 0x040E,
541        "client-error-compression-not-supported" => 0x040F,
542        "client-error-document-access-error" => 0x0412,
543        "server-error-internal-error" => 0x0500,
544        "server-error-operation-not-supported" => 0x0501,
545        "server-error-service-unavailable" => 0x0502,
546        "server-error-version-not-supported" => 0x0503,
547        "server-error-device-error" => 0x0504,
548        "server-error-temporary-error" => 0x0505,
549        "server-error-not-accepting-jobs" => 0x0506,
550        "server-error-busy" => 0x0507,
551        "server-error-job-canceled" => 0x0508,
552        other => {
553            tracing::warn!(
554                "Unknown IPP status name '{}', encoding server-error-internal-error",
555                other
556            );
557            0x0500
558        }
559    }
560}
561
562/// `printer-state` is an enum in IPP, not a keyword: clients read the integer.
563fn printer_state_enum(value: &str) -> Option<i32> {
564    match value {
565        "idle" => Some(3),
566        "processing" => Some(4),
567        "stopped" => Some(5),
568        _ => None,
569    }
570}
571
572/// `job-state` is likewise an enum.
573fn job_state_enum(value: &str) -> Option<i32> {
574    match value {
575        "pending" => Some(3),
576        "pending-held" | "held" => Some(4),
577        "processing" => Some(5),
578        "processing-stopped" | "stopped" => Some(6),
579        "canceled" => Some(7),
580        "aborted" => Some(8),
581        "completed" => Some(9),
582        _ => None,
583    }
584}
585
586/// Longest attribute name or value this encoder will write, in bytes.
587///
588/// IPP's `value-length` is two bytes, so anything past 65535 cannot be expressed at all. The
589/// previous encoder silently truncated to `u16::MAX` and wrote a message claiming the
590/// truncated length - self-consistent, and not what the model said. RFC 8011 §4.1.2 caps
591/// `text` at 1023 octets and `name` at 255 anyway; this bound exists to refuse rather than
592/// lie, not to enforce the spec's per-syntax limits.
593const MAX_IPP_FIELD_BYTES: usize = u16::MAX as usize;
594
595/// Maximum array/object nesting in a model-supplied attribute value.
596///
597/// The attribute map itself is not counted. Bound the tree before JSON fallback encoding,
598/// including values constructed directly in memory without serde_json's parser limits.
599pub const MAX_IPP_ATTRIBUTE_DEPTH: usize = 32;
600
601/// Refuse model-supplied attribute values the encoder cannot represent.
602///
603/// Run **before** `build_ipp_response`, which is the only point at which the original value is
604/// still visible: `as i32` on `4294967296` is `0`, and after the cast there is nothing left to
605/// check. Refusing beats clamping - `i32::MAX` is not what the model asked for either, and the
606/// message is what the repair loop reads.
607pub fn validate_attributes(attributes: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
608    for (name, value) in attributes {
609        if name.len() > MAX_IPP_FIELD_BYTES {
610            anyhow::bail!(
611                "attribute name is {} bytes; IPP's name-length field is two bytes",
612                name.len()
613            );
614        }
615
616        // Arrays are IPP sets; objects and nested containers are sent as JSON text. Walk
617        // both iteratively before any serialization, but preserve the integer checks on
618        // arrays without imposing IPP integer syntax on numbers inside JSON objects.
619        let mut pending = vec![(value, 0usize, true)];
620        while let Some((value, depth, check_scalar)) = pending.pop() {
621            if value.is_array() || value.is_object() {
622                if depth >= MAX_IPP_ATTRIBUTE_DEPTH {
623                    anyhow::bail!(
624                        "attribute '{name}' nesting exceeds the {MAX_IPP_ATTRIBUTE_DEPTH} level limit"
625                    );
626                }
627                match value {
628                    serde_json::Value::Array(values) => {
629                        pending.extend(values.iter().map(|value| (value, depth + 1, check_scalar)))
630                    }
631                    serde_json::Value::Object(values) => {
632                        pending.extend(values.values().map(|value| (value, depth + 1, false)))
633                    }
634                    _ => {}
635                }
636                continue;
637            }
638            if !check_scalar {
639                continue;
640            }
641            match value {
642                serde_json::Value::Number(n) => {
643                    let raw = n.as_i64().ok_or_else(|| {
644                        anyhow::anyhow!(
645                            "attribute '{name}' must be a whole number; IPP's integer syntax has \
646                         no fractional form"
647                        )
648                    })?;
649                    i32::try_from(raw).map_err(|_| {
650                        anyhow::anyhow!(
651                            "attribute '{name}' is {raw}, outside IPP's 32-bit signed integer \
652                         range (-2147483648 to 2147483647)"
653                        )
654                    })?;
655                }
656                serde_json::Value::String(text) if text.len() > MAX_IPP_FIELD_BYTES => {
657                    anyhow::bail!(
658                    "attribute '{name}' is {} bytes; IPP's value-length field is two bytes, so \
659                     {MAX_IPP_FIELD_BYTES} is the most that can be encoded",
660                    text.len()
661                );
662                }
663                _ => {}
664            }
665        }
666
667        // Match push_attributes: only the outer array is a set. Each nested array/object
668        // becomes one JSON-text field, whose escaped byte length must fit in full.
669        match value {
670            serde_json::Value::Array(values) => {
671                for value in values {
672                    validate_json_field(name, value)?;
673                }
674            }
675            value => validate_json_field(name, value)?,
676        }
677    }
678    Ok(())
679}
680
681/// Count the actual serialized bytes without allocating an oversized temporary string.
682fn validate_json_field(name: &str, value: &serde_json::Value) -> Result<()> {
683    if !value.is_object() && !value.is_array() {
684        return Ok(());
685    }
686    struct LengthWriter(usize);
687    impl std::io::Write for LengthWriter {
688        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
689            if bytes.len() > MAX_IPP_FIELD_BYTES - self.0 {
690                return Err(std::io::Error::new(
691                    std::io::ErrorKind::InvalidInput,
692                    "JSON text exceeds IPP's two-byte value-length field",
693                ));
694            }
695            self.0 += bytes.len();
696            Ok(bytes.len())
697        }
698
699        fn flush(&mut self) -> std::io::Result<()> {
700            Ok(())
701        }
702    }
703    serde_json::to_writer(LengthWriter(0), value)
704        .with_context(|| format!("attribute '{name}' cannot be encoded in full"))
705}
706
707/// Append a length-prefixed byte string using IPP's two-byte big-endian length.
708///
709/// The previous encoder wrote name lengths as `[0x00, len as u8]`, which silently truncated
710/// any name of 256 bytes or more and produced an unparseable message.
711///
712/// Model-supplied names and values are refused above [`MAX_IPP_FIELD_BYTES`] by
713/// [`validate_attributes`] before they ever reach here, so the clamp below is a backstop for
714/// server-authored literals, not a silent truncation of anything the model wrote.
715fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
716    let len = u16::try_from(bytes.len()).unwrap_or(u16::MAX);
717    out.extend_from_slice(&len.to_be_bytes());
718    out.extend_from_slice(&bytes[..len as usize]);
719}
720
721/// Encode one attribute value. `name` is empty for the second and later values of a set,
722/// which is how IPP expresses multi-valued attributes.
723fn push_attribute_value(out: &mut Vec<u8>, name: &str, attr_name: &str, value: &serde_json::Value) {
724    match value {
725        serde_json::Value::Bool(b) => {
726            out.push(TAG_BOOLEAN);
727            push_len_prefixed(out, name.as_bytes());
728            push_len_prefixed(out, &[u8::from(*b)]);
729        }
730        serde_json::Value::Number(n) => {
731            // `validate_attributes` proved every number in this map fits an i32 *before* the
732            // encoder ran, which is the only place a range check works: once narrowed, a
733            // wrapped value is indistinguishable from one the model meant. `job-id: 4294967296`
734            // used to arrive here as `as i32` and go out as 0, and a client reading job 0 has
735            // no way to know it was ever anything else.
736            let v = n.as_i64().unwrap_or(0) as i32;
737            out.push(TAG_INTEGER);
738            push_len_prefixed(out, name.as_bytes());
739            push_len_prefixed(out, &v.to_be_bytes());
740        }
741        serde_json::Value::String(s) => {
742            // Enum-valued attributes carry an integer on the wire; a client reading
743            // printer-state as a keyword string will not recognise it.
744            let as_enum = match attr_name {
745                "printer-state" => printer_state_enum(s),
746                "job-state" => job_state_enum(s),
747                _ => None,
748            };
749            if let Some(v) = as_enum {
750                out.push(TAG_ENUM);
751                push_len_prefixed(out, name.as_bytes());
752                push_len_prefixed(out, &v.to_be_bytes());
753                return;
754            }
755
756            let tag = if attr_name.contains("charset") {
757                TAG_CHARSET
758            } else if attr_name.contains("natural-language") {
759                TAG_NATURAL_LANGUAGE
760            } else if attr_name.ends_with("-uri") || attr_name.ends_with("-uri-supported") {
761                TAG_URI
762            } else if attr_name.contains("document-format")
763                || s.contains('/') && s.contains("application")
764            {
765                TAG_MIME_MEDIA_TYPE
766            } else if attr_name.ends_with("-name") {
767                TAG_NAME
768            } else if attr_name.ends_with("-supported")
769                || attr_name.ends_with("-default")
770                || attr_name.ends_with("-requested")
771                || attr_name.ends_with("-reasons")
772            {
773                TAG_KEYWORD
774            } else {
775                TAG_TEXT
776            };
777            out.push(tag);
778            push_len_prefixed(out, name.as_bytes());
779            push_len_prefixed(out, s.as_bytes());
780        }
781        other => {
782            // Objects and null have no IPP representation; send the JSON text so the value is
783            // at least visible to the operator rather than dropped silently.
784            out.push(TAG_TEXT);
785            push_len_prefixed(out, name.as_bytes());
786            push_len_prefixed(out, other.to_string().as_bytes());
787        }
788    }
789}
790
791/// Encode an attribute group's contents (the delimiter tag is written by the caller).
792fn push_attributes(out: &mut Vec<u8>, attributes: &serde_json::Map<String, serde_json::Value>) {
793    for (name, value) in attributes {
794        match value {
795            serde_json::Value::Array(values) => {
796                // Multi-valued: the first value carries the name, the rest carry a
797                // zero-length name (RFC 8010 "additional value").
798                for (idx, v) in values.iter().enumerate() {
799                    let value_name = if idx == 0 { name.as_str() } else { "" };
800                    push_attribute_value(out, value_name, name, v);
801                }
802                if values.is_empty() {
803                    // An attribute with no values still has to exist on the wire.
804                    out.push(TAG_KEYWORD);
805                    push_len_prefixed(out, name.as_bytes());
806                    push_len_prefixed(out, b"none");
807                }
808            }
809            v => push_attribute_value(out, name, name, v),
810        }
811    }
812}
813
814/// Build a complete IPP response message.
815///
816/// Layout: version(2) status(2) request-id(4) operation-group [extra-group] end-tag. The
817/// request-id is written as 0 and stamped with the request's real id by the HTTP handler.
818pub(crate) fn build_ipp_response(
819    status_code: u16,
820    status_message: Option<&str>,
821    group: Option<(u8, &serde_json::Map<String, serde_json::Value>)>,
822) -> Vec<u8> {
823    let mut out = Vec::new();
824
825    out.extend_from_slice(&[0x02, 0x00]); // IPP version 2.0
826    out.extend_from_slice(&status_code.to_be_bytes());
827    out.extend_from_slice(&0u32.to_be_bytes()); // request-id placeholder, stamped later
828
829    // Operation attributes group. attributes-charset and attributes-natural-language must be
830    // the first two attributes of the first group, in that order.
831    out.push(OPERATION_ATTRIBUTES_TAG);
832    out.push(TAG_CHARSET);
833    push_len_prefixed(&mut out, b"attributes-charset");
834    push_len_prefixed(&mut out, b"utf-8");
835    out.push(TAG_NATURAL_LANGUAGE);
836    push_len_prefixed(&mut out, b"attributes-natural-language");
837    push_len_prefixed(&mut out, b"en-us");
838    if let Some(message) = status_message {
839        out.push(TAG_TEXT);
840        push_len_prefixed(&mut out, b"status-message");
841        push_len_prefixed(&mut out, message.as_bytes());
842    }
843
844    if let Some((group_tag, attributes)) = group {
845        out.push(group_tag);
846        push_attributes(&mut out, attributes);
847    }
848
849    out.push(END_OF_ATTRIBUTES_TAG);
850    out
851}
852
853// ============================================================================
854// IPP Action Constants
855// ============================================================================
856
857pub static IPP_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(ipp_response_action);
858pub static IPP_PRINTER_ATTRIBUTES_ACTION: LazyLock<ActionDefinition> =
859    LazyLock::new(ipp_printer_attributes_action);
860pub static IPP_JOB_ATTRIBUTES_ACTION: LazyLock<ActionDefinition> =
861    LazyLock::new(ipp_job_attributes_action);
862
863// ============================================================================
864// IPP Event Type Constants
865// ============================================================================
866
867/// IPP request event - triggered when client sends an IPP request
868pub static IPP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
869    EventType::new(
870        "ipp_request_received",
871        "IPP request received from client",
872        json!({
873            "type": "ipp_printer_attributes",
874            "attributes": {
875                "printer-name": "NetGet Printer",
876                "printer-state": "idle",
877                "printer-is-accepting-jobs": true
878            }
879        }),
880    )
881    .with_parameters(vec![
882        Parameter {
883            name: "method".to_string(),
884            type_hint: "string".to_string(),
885            description: "HTTP method (POST for every real IPP operation)".to_string(),
886            required: true,
887        },
888        Parameter {
889            name: "uri".to_string(),
890            type_hint: "string".to_string(),
891            description: "Request URI, which names the printer or job queue".to_string(),
892            required: true,
893        },
894        Parameter {
895            name: "operation".to_string(),
896            type_hint: "string".to_string(),
897            description: "IPP operation name (e.g., Print-Job, Get-Printer-Attributes). \
898                          'Empty' when the request carried no IPP body, 'Malformed' when it \
899                          was too short to parse."
900                .to_string(),
901            required: true,
902        },
903        Parameter {
904            name: "request_id".to_string(),
905            type_hint: "number".to_string(),
906            description: "IPP request-id. Informational: the server echoes it into the \
907                          response itself, so no action needs to carry it."
908                .to_string(),
909            required: true,
910        },
911        Parameter {
912            name: "ipp_version".to_string(),
913            type_hint: "string".to_string(),
914            description: "IPP version the client used, e.g. '2.0'".to_string(),
915            required: true,
916        },
917        Parameter {
918            name: "answer_with".to_string(),
919            type_hint: "string".to_string(),
920            description: "Which response action answers this operation, and what it must say. \
921                          Derived by the server from the operation id."
922                .to_string(),
923            required: true,
924        },
925    ])
926    .with_actions(ipp_response_actions())
927    .with_alternative_example(json!({
928        "type": "ipp_job_attributes",
929        "attributes": {"job-id": 1, "job-state": "processing", "job-name": "document.pdf"}
930    }))
931    .with_alternative_example(json!({
932        "type": "ipp_response",
933        "ipp_status": "client-error-not-found",
934        "status_message": "No such printer"
935    }))
936    .with_log_template(
937        LogTemplate::new()
938            .with_info("IPP {operation}")
939            .with_debug("IPP operation={operation} uri={uri} request_id={request_id}")
940            .with_trace("IPP: {json_pretty(.)}"),
941    )
942});
943
944/// Get IPP event types
945/// The `answer_with` event field: which response action answers `operation`, in words.
946///
947/// The model is told the operation's name and has three response actions to choose from, and
948/// the eval showed it choosing by the operator's wording rather than by the operation: told a
949/// printer "is stopped and not accepting jobs", llama3.1:8b answered Get-Printer-Attributes with
950/// `server-error-not-accepting-jobs` — a status that belongs to Print-Job — every run, so the
951/// client learned nothing about the printer. The server knows the operation id exactly, so it
952/// says which action answers it rather than leaving a small model to map RFC 8011 in its head.
953pub fn answer_with_for_operation(operation: &str) -> &'static str {
954    match operation {
955        "Get-Printer-Attributes" => {
956            "ipp_printer_attributes alone, with ipp_status successful-ok: describe the \
957             printer (printer-name, printer-state, printer-is-accepting-jobs) whatever state \
958             it is in - a stopped printer is printer-state 'stopped', not an error"
959        }
960        "Print-Job" | "Print-URI" | "Create-Job" => {
961            "ipp_job_attributes with the new job's job-id and job-state; or, if the printer is \
962             not accepting jobs, ipp_response with ipp_status server-error-not-accepting-jobs"
963        }
964        "Get-Job-Attributes" | "Get-Jobs" => {
965            "ipp_job_attributes with the job's attributes; or ipp_response with ipp_status \
966             client-error-not-found for a job that does not exist"
967        }
968        _ => "ipp_response with the operation's status",
969    }
970}
971
972pub fn get_ipp_event_types() -> Vec<EventType> {
973    vec![IPP_REQUEST_EVENT.clone()]
974}