1use 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
25pub 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
40impl 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 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 .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 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 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 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 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
183impl 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 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 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
320fn 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
343pub 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
394pub 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
444pub 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
486const OPERATION_ATTRIBUTES_TAG: u8 = 0x01;
492const JOB_ATTRIBUTES_TAG: u8 = 0x02;
494const PRINTER_ATTRIBUTES_TAG: u8 = 0x04;
496const END_OF_ATTRIBUTES_TAG: u8 = 0x03;
498
499const 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
511pub const REQUEST_ID_OFFSET: usize = 4;
516
517pub(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
562fn 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
572fn 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
586const MAX_IPP_FIELD_BYTES: usize = u16::MAX as usize;
594
595pub const MAX_IPP_ATTRIBUTE_DEPTH: usize = 32;
600
601pub 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 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 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
681fn 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
707fn 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
721fn 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 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 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 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
791fn 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 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 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
814pub(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]); out.extend_from_slice(&status_code.to_be_bytes());
827 out.extend_from_slice(&0u32.to_be_bytes()); 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
853pub 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
863pub 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
944pub 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}