Skip to main content

ipp_printer_app/
attributes.rs

1//! Build `Get-Printer-Attributes` / `Validate-Job` IPP responses.
2
3use ipp::attribute::{IppAttribute, IppAttributes};
4use ipp::model::DelimiterTag;
5use ipp::prelude::*;
6use ipp::request::IppRequestResponse;
7use ipp::value::IppValue;
8
9use crate::printer::{IppPrinterState, PrinterRecord};
10
11fn kw(s: &str) -> IppValue {
12    IppValue::Keyword(s.try_into().expect("keyword"))
13}
14
15fn mime(s: &str) -> IppValue {
16    IppValue::MimeMediaType(s.try_into().expect("mime"))
17}
18
19fn uri(s: &str) -> IppValue {
20    IppValue::Uri(s.try_into().expect("uri"))
21}
22
23fn charset(s: &str) -> IppValue {
24    IppValue::Charset(s.try_into().expect("charset"))
25}
26
27fn lang(s: &str) -> IppValue {
28    IppValue::NaturalLanguage(s.try_into().expect("language"))
29}
30
31fn attr(name: &str, value: IppValue) -> IppAttribute {
32    IppAttribute::new(name.try_into().expect("attr name"), value)
33}
34
35fn add(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, value: IppValue) {
36    attrs.add(tag, attr(name, value));
37}
38
39fn add_array_keyword(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, items: &[&str]) {
40    let values: Vec<IppValue> = items.iter().map(|s| kw(s)).collect();
41    add(attrs, tag, name, IppValue::Array(values));
42}
43
44/// Advertise localhost when the server bound to an unspecified address.
45fn advertise_host(host: &str) -> &str {
46    if host == "0.0.0.0" || host == "::" || host.is_empty() {
47        "localhost"
48    } else {
49        host
50    }
51}
52
53/// Build the advertised printer URI for a record.
54fn printer_uri(record: &PrinterRecord, host: &str, port: u16) -> String {
55    format!(
56        "ipp://{}:{}/ipp/print/{}",
57        advertise_host(host),
58        port,
59        record.config.name
60    )
61}
62
63/// Build a successful Get-Printer-Attributes response.
64pub fn get_printer_attributes(
65    version: IppVersion,
66    request_id: u32,
67    record: &PrinterRecord,
68    host: &str,
69    port: u16,
70) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
71    let mut resp =
72        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
73    let attrs = resp.attributes_mut();
74    let cfg = &record.config;
75    let printer_uri_str = printer_uri(record, host, port);
76    let more_info = format!(
77        "http://{}:{}/",
78        advertise_host(host),
79        port
80    );
81
82    let p = DelimiterTag::PrinterAttributes;
83    add(attrs, p, "printer-uri-supported", uri(&printer_uri_str));
84    add(attrs, p, "uri-authentication-supported", kw("none"));
85    add(attrs, p, "uri-security-supported", kw("none"));
86    add(
87        attrs,
88        p,
89        "printer-name",
90        IppValue::NameWithoutLanguage(cfg.name.as_str().try_into().unwrap()),
91    );
92    add(
93        attrs,
94        p,
95        "printer-location",
96        IppValue::TextWithoutLanguage("".try_into().unwrap()),
97    );
98    add(
99        attrs,
100        p,
101        "printer-info",
102        IppValue::TextWithoutLanguage(cfg.make_and_model.as_str().try_into().unwrap()),
103    );
104    add(
105        attrs,
106        p,
107        "printer-make-and-model",
108        IppValue::TextWithoutLanguage(cfg.make_and_model.as_str().try_into().unwrap()),
109    );
110    add(attrs, p, "printer-more-info", uri(&more_info));
111    add(
112        attrs,
113        p,
114        "printer-uuid",
115        uri(&format!("urn:uuid:{}", record.uuid)),
116    );
117    add(
118        attrs,
119        p,
120        "printer-up-time",
121        IppValue::Integer(uptime_secs() as i32),
122    );
123
124    add(attrs, p, "printer-state", IppValue::Enum(record.state as i32));
125    let reason_kws: Vec<&str> = record.reasons.ipp_keywords();
126    add_array_keyword(attrs, p, "printer-state-reasons", &reason_kws);
127    add(attrs, p, "printer-is-accepting-jobs", IppValue::Boolean(true));
128    add(attrs, p, "queued-job-count", IppValue::Integer(0));
129
130    add_array_keyword(attrs, p, "ipp-versions-supported", &["1.1", "2.0", "2.1"]);
131    add_array_keyword(
132        attrs,
133        p,
134        "operations-supported",
135        &[
136            "Print-Job",
137            "Validate-Job",
138            "Get-Printer-Attributes",
139            "Get-Jobs",
140            "Get-Job-Attributes",
141            "Cancel-Job",
142        ],
143    );
144    add(
145        attrs,
146        p,
147        "charset-configured",
148        charset("utf-8"),
149    );
150    add(
151        attrs,
152        p,
153        "charset-supported",
154        IppValue::Array(vec![charset("utf-8")]),
155    );
156    add(attrs, p, "natural-language-configured", lang("en"));
157    add(
158        attrs,
159        p,
160        "natural-language-supported",
161        IppValue::Array(vec![lang("en")]),
162    );
163    add(
164        attrs,
165        p,
166        "generated-natural-language-supported",
167        IppValue::Array(vec![lang("en")]),
168    );
169    add_array_keyword(attrs, p, "compression-supported", &["none"]);
170
171    // PWG raster is the IPP Everywhere required format; the unified CUPS reader
172    // also handles legacy CUPS raster v1/v2 if a client picks that path.
173    add(
174        attrs,
175        p,
176        "document-format-supported",
177        IppValue::Array(vec![
178            mime("image/pwg-raster"),
179            mime("application/vnd.cups-raster"),
180            mime("application/octet-stream"),
181        ]),
182    );
183    add(
184        attrs,
185        p,
186        "document-format-default",
187        mime("image/pwg-raster"),
188    );
189    // PWG raster type for the everywhere driver.
190    add_array_keyword(
191        attrs,
192        p,
193        "pwg-raster-document-type-supported",
194        &["black_1"],
195    );
196    add(
197        attrs,
198        p,
199        "pwg-raster-document-resolution-supported",
200        IppValue::Array(vec![IppValue::Resolution {
201            cross_feed: cfg.dpi,
202            feed: cfg.dpi,
203            units: 3,
204        }]),
205    );
206    add_array_keyword(
207        attrs,
208        p,
209        "urf-supported",
210        &["W8", "SRGB24", "CP1", "RS203"],
211    );
212
213    add(attrs, p, "color-supported", IppValue::Boolean(false));
214    add_array_keyword(attrs, p, "print-color-mode-supported", &["monochrome"]);
215    add(attrs, p, "print-color-mode-default", kw("monochrome"));
216    add_array_keyword(attrs, p, "sides-supported", &["one-sided"]);
217    add(attrs, p, "sides-default", kw("one-sided"));
218    add(attrs, p, "orientation-requested-default", IppValue::Enum(3));
219
220    add(
221        attrs,
222        p,
223        "printer-resolution-default",
224        IppValue::Resolution {
225            cross_feed: cfg.dpi,
226            feed: cfg.dpi,
227            units: 3,
228        },
229    );
230    add(
231        attrs,
232        p,
233        "printer-resolution-supported",
234        IppValue::Array(vec![IppValue::Resolution {
235            cross_feed: cfg.dpi,
236            feed: cfg.dpi,
237            units: 3,
238        }]),
239    );
240
241    let media_kws: Vec<&str> = cfg.media_names.iter().map(|s| s.as_str()).collect();
242    if !media_kws.is_empty() {
243        add(attrs, p, "media-default", kw(media_kws[0]));
244        add_array_keyword(attrs, p, "media-supported", &media_kws);
245
246        // media-col-{default,supported} — required by IPP Everywhere.
247        let default_size = cfg.media_sizes.first().copied().unwrap_or([4000, 3000]);
248        add(
249            attrs,
250            p,
251            "media-col-default",
252            media_col(media_kws[0], default_size),
253        );
254        let media_cols: Vec<IppValue> = media_kws
255            .iter()
256            .zip(cfg.media_sizes.iter().copied().chain(std::iter::repeat(default_size)))
257            .map(|(name, size)| media_col(name, size))
258            .collect();
259        add(attrs, p, "media-col-supported", IppValue::Array(media_cols));
260    }
261
262    add(
263        attrs,
264        p,
265        "copies-supported",
266        IppValue::RangeOfInteger { min: 1, max: 999 },
267    );
268    add(attrs, p, "copies-default", IppValue::Integer(1));
269    add(
270        attrs,
271        p,
272        "print-quality-supported",
273        IppValue::Array(vec![
274            IppValue::Enum(3),
275            IppValue::Enum(4),
276            IppValue::Enum(5),
277        ]),
278    );
279    add(attrs, p, "print-quality-default", IppValue::Enum(4));
280
281    Ok(resp)
282}
283
284/// Validate-Job: same capability surface as Get-Printer-Attributes (success).
285pub fn validate_job(
286    version: IppVersion,
287    request_id: u32,
288    record: &PrinterRecord,
289    host: &str,
290    port: u16,
291) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
292    get_printer_attributes(version, request_id, record, host, port)
293}
294
295/// Build the `Print-Job` accepted response for a freshly-allocated job.
296pub fn print_job_accepted(
297    version: IppVersion,
298    request_id: u32,
299    job: &crate::job::JobRecord,
300    printer_uri_str: &str,
301) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
302    let mut resp =
303        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
304    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
305    let j = DelimiterTag::JobAttributes;
306    add(resp.attributes_mut(), j, "job-uri", uri(&job_uri_str));
307    add(
308        resp.attributes_mut(),
309        j,
310        "job-id",
311        IppValue::Integer(job.id as i32),
312    );
313    add(
314        resp.attributes_mut(),
315        j,
316        "job-state",
317        IppValue::Enum(job.state as i32),
318    );
319    add_array_keyword(
320        resp.attributes_mut(),
321        j,
322        "job-state-reasons",
323        &job_state_reason_keywords(job),
324    );
325    Ok(resp)
326}
327
328/// Build a `Get-Job-Attributes` response for a single job.
329pub fn build_job_attrs_response(
330    version: IppVersion,
331    request_id: u32,
332    job: &crate::job::JobRecord,
333    printer_uri_str: &str,
334) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
335    let mut resp =
336        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
337    append_job_attrs(resp.attributes_mut(), job, printer_uri_str);
338    Ok(resp)
339}
340
341/// Build a `Get-Jobs` response listing one job per group.
342pub fn build_get_jobs_response(
343    version: IppVersion,
344    request_id: u32,
345    jobs: &[crate::job::JobRecord],
346    printer_uri_str: &str,
347) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
348    let mut resp =
349        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
350    // Each job goes in its own JobAttributes group. The `ipp` crate's `add`
351    // merges all attrs with the same DelimiterTag into one group, which is
352    // wrong for multi-job responses — we push raw groups instead.
353    for job in jobs {
354        let mut group = ipp::attribute::IppAttributeGroup::new(DelimiterTag::JobAttributes);
355        for a in job_attrs_for_group(job, printer_uri_str) {
356            group
357                .attributes_mut()
358                .insert(a.name().to_owned(), a);
359        }
360        resp.attributes_mut().groups_mut().push(group);
361    }
362    Ok(resp)
363}
364
365fn append_job_attrs(
366    attrs: &mut IppAttributes,
367    job: &crate::job::JobRecord,
368    printer_uri_str: &str,
369) {
370    for a in job_attrs_for_group(job, printer_uri_str) {
371        attrs.add(DelimiterTag::JobAttributes, a);
372    }
373}
374
375fn job_attrs_for_group(
376    job: &crate::job::JobRecord,
377    printer_uri_str: &str,
378) -> Vec<IppAttribute> {
379    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
380    let mut out = vec![
381        attr("job-uri", uri(&job_uri_str)),
382        attr("job-id", IppValue::Integer(job.id as i32)),
383        attr("job-printer-uri", uri(printer_uri_str)),
384        attr(
385            "job-name",
386            IppValue::NameWithoutLanguage(
387                format!("job-{}", job.id).as_str().try_into().unwrap(),
388            ),
389        ),
390        attr("job-state", IppValue::Enum(job.state as i32)),
391        attr("time-at-creation", IppValue::Integer(job.created_secs())),
392    ];
393    let reason_kws = job_state_reason_keywords(job);
394    out.push(attr(
395        "job-state-reasons",
396        IppValue::Array(reason_kws.iter().map(|s| kw(s)).collect()),
397    ));
398    if !job.message.is_empty() {
399        out.push(attr(
400            "job-state-message",
401            IppValue::TextWithoutLanguage(job.message.as_str().try_into().unwrap()),
402        ));
403    }
404    if let Some(s) = job.completed_secs() {
405        out.push(attr("time-at-completed", IppValue::Integer(s)));
406    }
407    out
408}
409
410fn job_state_reason_keywords(job: &crate::job::JobRecord) -> Vec<&'static str> {
411    use crate::flags::PrinterReason;
412    use crate::job::JobState;
413    let mut out = Vec::new();
414    if job.reasons.contains(PrinterReason::MEDIA_EMPTY) {
415        out.push("job-completed-with-errors");
416    }
417    if job.reasons.contains(PrinterReason::MEDIA_JAM) {
418        out.push("aborted-by-system");
419    }
420    if job.reasons.contains(PrinterReason::OFFLINE) {
421        out.push("connection-error");
422    }
423    match job.state {
424        JobState::Canceled => out.push("job-canceled-by-user"),
425        JobState::Completed => out.push("job-completed-successfully"),
426        JobState::Aborted if out.is_empty() => out.push("aborted-by-system"),
427        _ => {}
428    }
429    if out.is_empty() {
430        out.push("none");
431    }
432    out
433}
434
435/// Transition the printer into `IppPrinterState::Processing`.
436pub fn set_printer_processing(record: &mut PrinterRecord) {
437    record.state = IppPrinterState::Processing;
438}
439
440/// Transition the printer back to `IppPrinterState::Idle`.
441pub fn set_printer_idle(record: &mut PrinterRecord) {
442    record.state = IppPrinterState::Idle;
443}
444
445/// Build a `media-col` collection with `media-size` (x/y in hundredths of mm)
446/// and `media-size-name`. CUPS expects PWG dimensions in hundredths of mm.
447fn media_col(name: &str, size_hmm: [i32; 2]) -> IppValue {
448    use std::collections::BTreeMap;
449    let mut size = BTreeMap::new();
450    size.insert(
451        "x-dimension".try_into().unwrap(),
452        IppValue::Integer(size_hmm[0]),
453    );
454    size.insert(
455        "y-dimension".try_into().unwrap(),
456        IppValue::Integer(size_hmm[1]),
457    );
458    let mut col = BTreeMap::new();
459    col.insert(
460        "media-size".try_into().unwrap(),
461        IppValue::Collection(size),
462    );
463    col.insert(
464        "media-size-name".try_into().unwrap(),
465        kw(name),
466    );
467    IppValue::Collection(col)
468}
469
470fn uptime_secs() -> u64 {
471    use std::sync::OnceLock;
472    use std::time::Instant;
473    static START: OnceLock<Instant> = OnceLock::new();
474    START.get_or_init(Instant::now).elapsed().as_secs()
475}