Skip to main content

ipp_printer_app/
attributes.rs

1//! Build `Get-Printer-Attributes` / `Validate-Job` IPP responses.
2
3use std::collections::BTreeSet;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use ipp::attribute::{IppAttribute, IppAttributes};
7use ipp::model::DelimiterTag;
8use ipp::prelude::*;
9use ipp::request::IppRequestResponse;
10use ipp::value::IppValue;
11
12use crate::printer::{IppPrinterState, PrinterRecord};
13
14fn kw(s: &str) -> IppValue {
15    IppValue::Keyword(s.try_into().expect("keyword"))
16}
17
18fn mime(s: &str) -> IppValue {
19    IppValue::MimeMediaType(s.try_into().expect("mime"))
20}
21
22fn uri(s: &str) -> IppValue {
23    IppValue::Uri(s.try_into().expect("uri"))
24}
25
26fn charset(s: &str) -> IppValue {
27    IppValue::Charset(s.try_into().expect("charset"))
28}
29
30fn lang(s: &str) -> IppValue {
31    IppValue::NaturalLanguage(s.try_into().expect("language"))
32}
33
34fn attr(name: &str, value: IppValue) -> IppAttribute {
35    IppAttribute::new(name.try_into().expect("attr name"), value)
36}
37
38fn add(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, value: IppValue) {
39    attrs.add(tag, attr(name, value));
40}
41
42fn add_array_keyword(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, items: &[&str]) {
43    let values: Vec<IppValue> = items.iter().map(|s| kw(s)).collect();
44    add(attrs, tag, name, IppValue::Array(values));
45}
46
47fn text(s: &str) -> IppValue {
48    IppValue::TextWithoutLanguage(s.try_into().expect("text"))
49}
50
51fn add_array_enum(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, codes: &[i32]) {
52    let values: Vec<IppValue> = codes.iter().map(|c| IppValue::Enum(*c)).collect();
53    add(attrs, tag, name, IppValue::Array(values));
54}
55
56/// Break a Unix timestamp into a civil UTC `dateTime` value (Hinnant's
57/// `civil_from_days`). IPP `dateTime` (RFC 2579 / RFC 8011 §5.1.15).
58fn datetime_utc(unix_secs: i64) -> IppValue {
59    let days = unix_secs.div_euclid(86_400);
60    let rem = unix_secs.rem_euclid(86_400);
61    let (hour, minutes, seconds) = (rem / 3600, (rem % 3600) / 60, rem % 60);
62
63    let z = days + 719_468;
64    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
65    let doe = z - era * 146_097;
66    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
67    let year = yoe + era * 400;
68    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
69    let mp = (5 * doy + 2) / 153;
70    let day = doy - (153 * mp + 2) / 5 + 1;
71    let month = if mp < 10 { mp + 3 } else { mp - 9 };
72    let year = if month <= 2 { year + 1 } else { year };
73
74    IppValue::DateTime {
75        year: year as u16,
76        month: month as u8,
77        day: day as u8,
78        hour: hour as u8,
79        minutes: minutes as u8,
80        seconds: seconds as u8,
81        deci_seconds: 0,
82        utc_dir: '+',
83        utc_hours: 0,
84        utc_mins: 0,
85    }
86}
87
88fn now_unix() -> i64 {
89    SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .map(|d| d.as_secs() as i64)
92        .unwrap_or(0)
93}
94
95/// Advertise localhost when the server bound to an unspecified address.
96fn advertise_host(host: &str) -> &str {
97    if host == "0.0.0.0" || host == "::" || host.is_empty() {
98        "localhost"
99    } else {
100        host
101    }
102}
103
104/// Build the advertised printer URI for a record.
105fn printer_uri(record: &PrinterRecord, host: &str, port: u16) -> String {
106    format!(
107        "ipp://{}:{}/ipp/print/{}",
108        advertise_host(host),
109        port,
110        record.config.name
111    )
112}
113
114/// Build a successful Get-Printer-Attributes response.
115///
116/// `requested` is the client's `requested-attributes` set. `None` (or a set
117/// containing the magic value `all`) returns the full attribute group; a
118/// concrete set filters the response down to the named attributes (RFC 8011
119/// §4.2.5).
120pub fn get_printer_attributes(
121    version: IppVersion,
122    request_id: u32,
123    record: &PrinterRecord,
124    host: &str,
125    port: u16,
126    requested: Option<&BTreeSet<String>>,
127) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
128    let mut resp = IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
129    let attrs = resp.attributes_mut();
130    let cfg = &record.config;
131    let printer_uri_str = printer_uri(record, host, port);
132    let more_info = format!("http://{}:{}/", advertise_host(host), port);
133
134    let p = DelimiterTag::PrinterAttributes;
135    add(attrs, p, "printer-uri-supported", uri(&printer_uri_str));
136    add(attrs, p, "uri-authentication-supported", kw("none"));
137    add(attrs, p, "uri-security-supported", kw("none"));
138    add(
139        attrs,
140        p,
141        "printer-name",
142        IppValue::NameWithoutLanguage(cfg.name.as_str().try_into().unwrap()),
143    );
144    add(
145        attrs,
146        p,
147        "printer-location",
148        IppValue::TextWithoutLanguage("".try_into().unwrap()),
149    );
150    add(
151        attrs,
152        p,
153        "printer-info",
154        IppValue::TextWithoutLanguage(cfg.display_label().try_into().unwrap()),
155    );
156    add(
157        attrs,
158        p,
159        "printer-make-and-model",
160        IppValue::TextWithoutLanguage(cfg.make_and_model.as_str().try_into().unwrap()),
161    );
162    add(attrs, p, "printer-more-info", uri(&more_info));
163    add(
164        attrs,
165        p,
166        "printer-uuid",
167        uri(&format!("urn:uuid:{}", record.uuid)),
168    );
169    // RFC 8011 §5.4.29: seconds since the printer started; must be > 0 (the
170    // uptime clock starts lazily, so floor it at 1 for requests in the first
171    // second).
172    add(
173        attrs,
174        p,
175        "printer-up-time",
176        IppValue::Integer(uptime_secs().max(1) as i32),
177    );
178
179    add(
180        attrs,
181        p,
182        "printer-state",
183        IppValue::Enum(record.state as i32),
184    );
185    let reason_kws: Vec<&str> = record.reasons.ipp_keywords();
186    add_array_keyword(attrs, p, "printer-state-reasons", &reason_kws);
187    add(
188        attrs,
189        p,
190        "printer-is-accepting-jobs",
191        IppValue::Boolean(true),
192    );
193    add(attrs, p, "queued-job-count", IppValue::Integer(0));
194
195    add_array_keyword(attrs, p, "ipp-versions-supported", &["1.1", "2.0", "2.1"]);
196    add_array_keyword(attrs, p, "ipp-features-supported", &["ipp-everywhere"]);
197    add(attrs, p, "pdl-override-supported", kw("attempted"));
198    if !cfg.device_id.is_empty() {
199        add(
200            attrs,
201            p,
202            "printer-device-id",
203            IppValue::TextWithoutLanguage(cfg.device_id.as_str().try_into().unwrap()),
204        );
205    }
206    // RFC 8011 §5.4.15: `1setOf enum` carrying the operation *codes*, not
207    // keyword names. Order follows the numeric code.
208    add_array_enum(
209        attrs,
210        p,
211        "operations-supported",
212        &[
213            0x0002, // Print-Job
214            0x0004, // Validate-Job
215            0x0005, // Create-Job
216            0x0006, // Send-Document
217            0x0008, // Cancel-Job
218            0x0009, // Get-Job-Attributes
219            0x000a, // Get-Jobs
220            0x000b, // Get-Printer-Attributes
221            0x0039, // Cancel-My-Jobs
222            0x003b, // Close-Job
223            0x003c, // Identify-Printer
224        ],
225    );
226    add(attrs, p, "charset-configured", charset("utf-8"));
227    add(
228        attrs,
229        p,
230        "charset-supported",
231        IppValue::Array(vec![charset("utf-8")]),
232    );
233    add(attrs, p, "natural-language-configured", lang("en"));
234    add(
235        attrs,
236        p,
237        "natural-language-supported",
238        IppValue::Array(vec![lang("en")]),
239    );
240    add(
241        attrs,
242        p,
243        "generated-natural-language-supported",
244        IppValue::Array(vec![lang("en")]),
245    );
246    add_array_keyword(attrs, p, "compression-supported", &["none"]);
247
248    // PWG raster is the IPP Everywhere required format; the unified CUPS reader
249    // also handles legacy CUPS raster v1/v2 if a client picks that path. The
250    // consumer may extend this via `PrinterConfig::document_formats` (e.g. to
251    // advertise `image/jpeg` once its backend decodes it).
252    let format_values: Vec<IppValue> = if cfg.document_formats.is_empty() {
253        vec![
254            mime("image/pwg-raster"),
255            mime("application/vnd.cups-raster"),
256            mime("application/octet-stream"),
257        ]
258    } else {
259        cfg.document_formats.iter().map(|f| mime(f)).collect()
260    };
261    add(
262        attrs,
263        p,
264        "document-format-supported",
265        IppValue::Array(format_values),
266    );
267    add(
268        attrs,
269        p,
270        "document-format-default",
271        mime("image/pwg-raster"),
272    );
273    // PWG raster type for the everywhere driver.
274    add_array_keyword(attrs, p, "pwg-raster-document-type-supported", &["black_1"]);
275    // PWG 5102.4 §6.2.1: `1setOf resolution`. CUPS 2.4.16+ (Ubuntu 26.04)
276    // requires this typing; 2.4.10 (Debian trixie) regrettably has a bug
277    // and looks it up as IPP_TAG_KEYWORD instead — the spec form wins.
278    add(
279        attrs,
280        p,
281        "pwg-raster-document-resolution-supported",
282        IppValue::Array(vec![IppValue::Resolution {
283            cross_feed: cfg.dpi,
284            feed: cfg.dpi,
285            units: 3,
286        }]),
287    );
288    add_array_keyword(attrs, p, "urf-supported", &["W8", "SRGB24", "CP1", "RS203"]);
289
290    add(attrs, p, "color-supported", IppValue::Boolean(false));
291    add_array_keyword(attrs, p, "print-color-mode-supported", &["monochrome"]);
292    add(attrs, p, "print-color-mode-default", kw("monochrome"));
293    add_array_keyword(attrs, p, "sides-supported", &["one-sided"]);
294    add(attrs, p, "sides-default", kw("one-sided"));
295    add(attrs, p, "orientation-requested-default", IppValue::Enum(3));
296    // portrait / landscape / reverse-landscape / reverse-portrait (RFC 8011).
297    add_array_enum(attrs, p, "orientation-requested-supported", &[3, 4, 5, 6]);
298
299    // Identify-Printer actions (PWG 5100.14 §5.1). The framework dispatches
300    // the operation to `DeviceBackend::identify`; we advertise a display-type
301    // action which any backend can honour (a beep/LED maps to `sound`/`flash`).
302    add_array_keyword(
303        attrs,
304        p,
305        "identify-actions-supported",
306        &["display", "sound"],
307    );
308    add_array_keyword(attrs, p, "identify-actions-default", &["display"]);
309
310    // IPP Everywhere required descriptors. We expose conservative defaults that
311    // satisfy CUPS' `-m everywhere` PPD generator without claiming features
312    // we don't implement (no real trays, no finishings).
313    add_array_keyword(attrs, p, "media-source-supported", &["main"]);
314    add_array_keyword(attrs, p, "media-type-supported", &["labels", "stationery"]);
315    add_array_keyword(attrs, p, "output-bin-supported", &["face-up"]);
316    add(attrs, p, "output-bin-default", kw("face-up"));
317    add_array_keyword(
318        attrs,
319        p,
320        "print-content-optimize-supported",
321        &["auto", "graphic", "photo", "text", "text-and-graphic"],
322    );
323    add(attrs, p, "print-content-optimize-default", kw("auto"));
324    // RFC 8011 §5.2.6: `1setOf enum`. `3` == `none`.
325    add_array_enum(attrs, p, "finishings-supported", &[3]);
326    add(attrs, p, "finishings-default", IppValue::Enum(3));
327    add(
328        attrs,
329        p,
330        "job-creation-attributes-supported",
331        IppValue::Array(vec![
332            kw("copies"),
333            kw("media"),
334            kw("media-col"),
335            kw("orientation-requested"),
336            kw("print-color-mode"),
337            kw("print-content-optimize"),
338            kw("print-quality"),
339            kw("printer-resolution"),
340            kw("sides"),
341        ]),
342    );
343
344    add(
345        attrs,
346        p,
347        "printer-resolution-default",
348        IppValue::Resolution {
349            cross_feed: cfg.dpi,
350            feed: cfg.dpi,
351            units: 3,
352        },
353    );
354    add(
355        attrs,
356        p,
357        "printer-resolution-supported",
358        IppValue::Array(vec![IppValue::Resolution {
359            cross_feed: cfg.dpi,
360            feed: cfg.dpi,
361            units: 3,
362        }]),
363    );
364
365    // PWG 5101.1 custom-size bookends. Advertising these alongside the
366    // enumerated names is what makes a desktop dialog offer a Custom entry;
367    // without them a client can only pick from the fixed list.
368    let custom_range = custom_media_range(cfg);
369    let custom_kws: Vec<String> = custom_range
370        .iter()
371        .flat_map(|(min, max)| {
372            [
373                custom_media_name("min", *min),
374                custom_media_name("max", *max),
375            ]
376        })
377        .collect();
378
379    let media_kws: Vec<&str> = cfg
380        .media_names
381        .iter()
382        .map(|s| s.as_str())
383        .chain(custom_kws.iter().map(|s| s.as_str()))
384        .collect();
385
386    if !media_kws.is_empty() {
387        add(attrs, p, "media-default", kw(media_kws[0]));
388        add_array_keyword(attrs, p, "media-supported", &media_kws);
389
390        // media-col-{default} — required by IPP Everywhere.
391        let default_size = cfg.media_sizes.first().copied().unwrap_or([4000, 3000]);
392        add(
393            attrs,
394            p,
395            "media-col-default",
396            media_col(
397                media_kws[0],
398                default_size,
399                side_margin_hmm(cfg, default_size[0]),
400            ),
401        );
402        // Pair each enumerated name with its own size, then append the custom
403        // bookends carrying *their* bounds. Zipping the whole keyword list
404        // against the size list would run the bookends past the end and give
405        // them the default size, contradicting the dimensions in their names.
406        let mut media_cols: Vec<IppValue> = cfg
407            .media_names
408            .iter()
409            .zip(
410                cfg.media_sizes
411                    .iter()
412                    .copied()
413                    .chain(std::iter::repeat(default_size)),
414            )
415            .map(|(name, size)| media_col(name, size, side_margin_hmm(cfg, size[0])))
416            .collect();
417        if let Some((min, max)) = custom_range {
418            media_cols.push(media_col(
419                &custom_media_name("min", min),
420                min,
421                side_margin_hmm(cfg, min[0]),
422            ));
423            media_cols.push(media_col(
424                &custom_media_name("max", max),
425                max,
426                side_margin_hmm(cfg, max[0]),
427            ));
428        }
429        // PWG 5100.13: `media-col-supported` is `1setOf keyword` naming the
430        // member attributes a client may set in a `media-col` collection — NOT
431        // the collections themselves (that's `media-col-database`, which CUPS'
432        // `lpadmin -m everywhere` PPD generator walks to enumerate sizes).
433        add_array_keyword(
434            attrs,
435            p,
436            "media-col-supported",
437            &[
438                "media-size",
439                "media-size-name",
440                "media-top-margin",
441                "media-bottom-margin",
442                "media-left-margin",
443                "media-right-margin",
444                "media-source",
445                "media-type",
446            ],
447        );
448        add(
449            attrs,
450            p,
451            "media-col-database",
452            IppValue::Array(media_cols.clone()),
453        );
454
455        // `media-size-supported` (PWG 5100.12 §6.3.x): `1setOf collection` of
456        // bare `media-size` (x/y only), distinct from `media-col-database`.
457        let mut media_sizes: Vec<IppValue> = cfg
458            .media_sizes
459            .iter()
460            .copied()
461            .map(media_size_col)
462            .collect();
463        // A range entry alongside the discrete ones: PWG 5100.12 allows
464        // x/y-dimension to be a rangeOfInteger, which is how a printer says
465        // "anything between these bounds", and how CUPS derives its Custom
466        // page-size support.
467        if let Some((min, max)) = custom_range {
468            media_sizes.push(media_size_range_col(min, max));
469        }
470        add(
471            attrs,
472            p,
473            "media-size-supported",
474            IppValue::Array(media_sizes),
475        );
476
477        // media-ready / media-col-ready — the loaded media. The status poller
478        // fills `record.ready_media` with live roll data; absent that we fall
479        // back to the configured default so the (required) attributes exist.
480        let (ready_name, ready_size) = match &record.ready_media {
481            Some(rm) => (rm.name.as_str(), rm.size_hmm),
482            None => (media_kws[0], default_size),
483        };
484        add(attrs, p, "media-ready", kw(ready_name));
485        add(
486            attrs,
487            p,
488            "media-col-ready",
489            IppValue::Array(vec![media_col(
490                ready_name,
491                ready_size,
492                side_margin_hmm(cfg, ready_size[0]),
493            )]),
494        );
495    }
496
497    // Hard margins, in hundredths of a millimetre. The feed direction is
498    // unbounded on a roll, so top/bottom are 0; the sides carry whatever the
499    // printhead cannot reach for each advertised width. The supported sets
500    // must contain every value any `media-col-database` entry reports, or a
501    // client is entitled to reject the collection.
502    for margin in [
503        "media-top-margin-supported",
504        "media-bottom-margin-supported",
505    ] {
506        add(attrs, p, margin, IppValue::Integer(0));
507    }
508    let mut side_margins: Vec<i32> = cfg
509        .media_sizes
510        .iter()
511        .map(|s| side_margin_hmm(cfg, s[0]))
512        .collect();
513    if let Some((min, max)) = custom_media_range(cfg) {
514        side_margins.push(side_margin_hmm(cfg, min[0]));
515        side_margins.push(side_margin_hmm(cfg, max[0]));
516    }
517    // Only advertise 0 if some size really is fully printable. CUPS takes the
518    // *maximum* of this set as the page default and emits a borderless variant
519    // for the minimum, so an unconditional 0 keeps a full-width page size on
520    // offer — and picking it puts the artwork straight back under the crop.
521    if side_margins.is_empty() {
522        side_margins.push(0);
523    }
524    side_margins.sort_unstable();
525    side_margins.dedup();
526    let side_margins: Vec<IppValue> = side_margins.into_iter().map(IppValue::Integer).collect();
527    for margin in [
528        "media-left-margin-supported",
529        "media-right-margin-supported",
530    ] {
531        add(attrs, p, margin, IppValue::Array(side_margins.clone()));
532    }
533
534    add(
535        attrs,
536        p,
537        "copies-supported",
538        IppValue::RangeOfInteger { min: 1, max: 999 },
539    );
540    add(attrs, p, "copies-default", IppValue::Integer(1));
541    add(
542        attrs,
543        p,
544        "print-quality-supported",
545        IppValue::Array(vec![
546            IppValue::Enum(3),
547            IppValue::Enum(4),
548            IppValue::Enum(5),
549        ]),
550    );
551    add(attrs, p, "print-quality-default", IppValue::Enum(4));
552
553    // --- Job/limit descriptors (PWG 5100.14 §5.x, mostly static) ---
554    add(
555        attrs,
556        p,
557        "multiple-document-jobs-supported",
558        IppValue::Boolean(false),
559    );
560    add(
561        attrs,
562        p,
563        "multiple-operation-time-out",
564        IppValue::Integer(60),
565    );
566    add(
567        attrs,
568        p,
569        "multiple-operation-time-out-action",
570        kw("process-job"),
571    );
572    add(attrs, p, "job-ids-supported", IppValue::Boolean(true));
573    add(
574        attrs,
575        p,
576        "preferred-attributes-supported",
577        IppValue::Boolean(false),
578    );
579    add_array_keyword(
580        attrs,
581        p,
582        "overrides-supported",
583        &["document-number", "pages"],
584    );
585    add_array_keyword(
586        attrs,
587        p,
588        "printer-get-attributes-supported",
589        &["document-format"],
590    );
591    add_array_keyword(
592        attrs,
593        p,
594        "which-jobs-supported",
595        &[
596            "completed",
597            "not-completed",
598            "aborted",
599            "canceled",
600            "pending",
601            "processing",
602        ],
603    );
604
605    // --- Rendering descriptors ---
606    add(attrs, p, "print-rendering-intent-default", kw("auto"));
607    add_array_keyword(attrs, p, "print-rendering-intent-supported", &["auto"]);
608    // One-sided printer: the back side is rendered the same way as the front.
609    add(attrs, p, "pwg-raster-document-sheet-back", kw("normal"));
610
611    // --- Identity / admin descriptors ---
612    // Location is not known to the framework; out-of-band `unknown` is the
613    // honest value (a real `geo:` URI would be fabricated coordinates).
614    add(
615        attrs,
616        p,
617        "printer-geo-location",
618        IppValue::Other {
619            tag: 0x12,
620            data: Vec::<u8>::new().into(),
621        },
622    );
623    add(attrs, p, "printer-organization", text(""));
624    add(attrs, p, "printer-organizational-unit", text(""));
625    add(
626        attrs,
627        p,
628        "printer-icons",
629        IppValue::Array(vec![uri(&format!(
630            "http://{}:{}/icon.png",
631            advertise_host(host),
632            port
633        ))]),
634    );
635    add(attrs, p, "pages-per-minute", IppValue::Integer(20));
636
637    // --- Supply / consumable (PWG 5100.14). The device backend can overwrite
638    // these per-poll with the real labels-remaining gauge; the static fallback
639    // keeps the required attributes present. ---
640    // Live remaining-supply level from the poller, else assume full.
641    let supply_level = record.supply_percent.unwrap_or(100);
642    add(
643        attrs,
644        p,
645        "printer-supply",
646        IppValue::Array(vec![IppValue::OctetString(
647            format!(
648                "index=1;class=supplyThatIsConsumed;type=stoppingMaterial;\
649                 unit=percent;maxcapacity=100;level={supply_level};colorantname=unknown;"
650            )
651            .try_into()
652            .expect("supply"),
653        )]),
654    );
655    add(
656        attrs,
657        p,
658        "printer-supply-description",
659        IppValue::Array(vec![text("Label Stock")]),
660    );
661    add(
662        attrs,
663        p,
664        "printer-supply-info-uri",
665        uri(&format!("http://{}:{}/", advertise_host(host), port)),
666    );
667
668    // --- Change tracking (RFC 8011 §5.4.26-29) ---
669    let now = now_unix();
670    add(
671        attrs,
672        p,
673        "printer-config-change-time",
674        IppValue::Integer(uptime_secs() as i32),
675    );
676    add(
677        attrs,
678        p,
679        "printer-config-change-date-time",
680        datetime_utc(now),
681    );
682    add(
683        attrs,
684        p,
685        "printer-state-change-time",
686        IppValue::Integer(uptime_secs() as i32),
687    );
688    add(
689        attrs,
690        p,
691        "printer-state-change-date-time",
692        datetime_utc(now),
693    );
694
695    filter_requested(&mut resp, requested);
696    Ok(resp)
697}
698
699/// Apply `requested-attributes` filtering to a freshly-built
700/// Get-Printer-Attributes response. `None` or a set containing the magic
701/// value `all` is a no-op (return everything). Otherwise the printer-attribute
702/// group is reduced to the explicitly-named attributes (RFC 8011 §4.2.5). The
703/// always-present operation attributes (charset / language) are preserved.
704fn filter_requested(resp: &mut IppRequestResponse, requested: Option<&BTreeSet<String>>) {
705    let Some(set) = requested else { return };
706    if set.is_empty() || set.contains("all") {
707        return;
708    }
709    for group in resp.attributes_mut().groups_mut() {
710        if group.tag() != DelimiterTag::PrinterAttributes {
711            continue;
712        }
713        group
714            .attributes_mut()
715            .retain(|name, _| set.contains(name.as_str()));
716    }
717}
718
719/// Validate-Job: same capability surface as Get-Printer-Attributes (success).
720pub fn validate_job(
721    version: IppVersion,
722    request_id: u32,
723    record: &PrinterRecord,
724    host: &str,
725    port: u16,
726) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
727    get_printer_attributes(version, request_id, record, host, port, None)
728}
729
730/// Build the `Print-Job` accepted response for a freshly-allocated job.
731pub fn print_job_accepted(
732    version: IppVersion,
733    request_id: u32,
734    job: &crate::job::JobRecord,
735    printer_uri_str: &str,
736) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
737    let mut resp = IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
738    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
739    let j = DelimiterTag::JobAttributes;
740    add(resp.attributes_mut(), j, "job-uri", uri(&job_uri_str));
741    add(
742        resp.attributes_mut(),
743        j,
744        "job-id",
745        IppValue::Integer(job.id as i32),
746    );
747    add(
748        resp.attributes_mut(),
749        j,
750        "job-state",
751        IppValue::Enum(job.state as i32),
752    );
753    add_array_keyword(
754        resp.attributes_mut(),
755        j,
756        "job-state-reasons",
757        &job_state_reason_keywords(job),
758    );
759    Ok(resp)
760}
761
762/// Build a `Get-Job-Attributes` response for a single job. `requested` filters
763/// the returned attributes (`None` = all, the Get-Job-Attributes default).
764pub fn build_job_attrs_response(
765    version: IppVersion,
766    request_id: u32,
767    job: &crate::job::JobRecord,
768    printer_uri_str: &str,
769    requested: Option<&BTreeSet<String>>,
770) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
771    let mut resp = IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
772    for a in job_attrs_for_group(job, printer_uri_str, requested) {
773        resp.attributes_mut().add(DelimiterTag::JobAttributes, a);
774    }
775    Ok(resp)
776}
777
778/// Build a `Get-Jobs` response listing one job per group. `requested` filters
779/// the per-job attributes; the Get-Jobs default (`None`) is `job-uri` +
780/// `job-id` only (RFC 8011 §3.2.6.1), supplied by the caller.
781pub fn build_get_jobs_response(
782    version: IppVersion,
783    request_id: u32,
784    jobs: &[crate::job::JobRecord],
785    printer_uri_str: &str,
786    requested: Option<&BTreeSet<String>>,
787) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
788    let mut resp = IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
789    // Each job goes in its own JobAttributes group. The `ipp` crate's `add`
790    // merges all attrs with the same DelimiterTag into one group, which is
791    // wrong for multi-job responses — we push raw groups instead.
792    for job in jobs {
793        let mut group = ipp::attribute::IppAttributeGroup::new(DelimiterTag::JobAttributes);
794        for a in job_attrs_for_group(job, printer_uri_str, requested) {
795            group.attributes_mut().insert(a.name().to_owned(), a);
796        }
797        resp.attributes_mut().groups_mut().push(group);
798    }
799    Ok(resp)
800}
801
802fn job_attrs_for_group(
803    job: &crate::job::JobRecord,
804    printer_uri_str: &str,
805    requested: Option<&BTreeSet<String>>,
806) -> Vec<IppAttribute> {
807    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
808    let mut out = vec![
809        attr("job-uri", uri(&job_uri_str)),
810        attr("job-id", IppValue::Integer(job.id as i32)),
811        attr("job-printer-uri", uri(printer_uri_str)),
812        attr(
813            "job-name",
814            IppValue::NameWithoutLanguage(format!("job-{}", job.id).as_str().try_into().unwrap()),
815        ),
816        attr("job-state", IppValue::Enum(job.state as i32)),
817        attr(
818            "job-originating-user-name",
819            IppValue::NameWithoutLanguage(
820                job.owner
821                    .as_str()
822                    .try_into()
823                    .unwrap_or_else(|_| "anonymous".try_into().expect("anonymous")),
824            ),
825        ),
826        attr("time-at-creation", IppValue::Integer(job.created_secs())),
827    ];
828    let reason_kws = job_state_reason_keywords(job);
829    out.push(attr(
830        "job-state-reasons",
831        IppValue::Array(reason_kws.iter().map(|s| kw(s)).collect()),
832    ));
833    if !job.message.is_empty() {
834        out.push(attr(
835            "job-state-message",
836            IppValue::TextWithoutLanguage(job.message.as_str().try_into().unwrap()),
837        ));
838    }
839    // We don't separately track when processing began; the mock pipeline
840    // starts work as soon as the job is accepted, so creation time is a faithful
841    // stand-in. Required by RFC 8011 (no-value|integer).
842    out.push(attr(
843        "time-at-processing",
844        IppValue::Integer(job.created_secs()),
845    ));
846    out.push(attr(
847        "job-printer-up-time",
848        IppValue::Integer(uptime_secs() as i32),
849    ));
850    if let Some(s) = job.completed_secs() {
851        out.push(attr("time-at-completed", IppValue::Integer(s)));
852    }
853    if let Some(set) = requested {
854        out.retain(|a| set.contains(a.name().as_str()));
855    }
856    out
857}
858
859fn job_state_reason_keywords(job: &crate::job::JobRecord) -> Vec<&'static str> {
860    use crate::flags::PrinterReason;
861    use crate::job::JobState;
862    let mut out = Vec::new();
863    if job.reasons.contains(PrinterReason::MEDIA_EMPTY) {
864        out.push("job-completed-with-errors");
865    }
866    if job.reasons.contains(PrinterReason::MEDIA_JAM) {
867        out.push("aborted-by-system");
868    }
869    if job.reasons.contains(PrinterReason::OFFLINE) {
870        out.push("connection-error");
871    }
872    match job.state {
873        JobState::Canceled => out.push("job-canceled-by-user"),
874        JobState::Completed => out.push("job-completed-successfully"),
875        JobState::Aborted if out.is_empty() => out.push("aborted-by-system"),
876        _ => {}
877    }
878    if out.is_empty() {
879        out.push("none");
880    }
881    out
882}
883
884/// Transition the printer into `IppPrinterState::Processing`.
885pub fn set_printer_processing(record: &mut PrinterRecord) {
886    record.state = IppPrinterState::Processing;
887}
888
889/// Transition the printer back to `IppPrinterState::Idle`.
890pub fn set_printer_idle(record: &mut PrinterRecord) {
891    record.state = IppPrinterState::Idle;
892}
893
894/// Build a `media-col` collection with `media-size` (x/y in hundredths of mm)
895/// and `media-size-name`. CUPS expects PWG dimensions in hundredths of mm.
896fn media_col(name: &str, size_hmm: [i32; 2], side_margin_hmm: i32) -> IppValue {
897    use std::collections::BTreeMap;
898    let mut size = BTreeMap::new();
899    size.insert(
900        "x-dimension".try_into().unwrap(),
901        IppValue::Integer(size_hmm[0]),
902    );
903    size.insert(
904        "y-dimension".try_into().unwrap(),
905        IppValue::Integer(size_hmm[1]),
906    );
907    let mut col = BTreeMap::new();
908    col.insert("media-size".try_into().unwrap(), IppValue::Collection(size));
909    col.insert("media-size-name".try_into().unwrap(), kw(name));
910    // Feed direction is unbounded on a roll; only the head's width constrains
911    // the imageable area.
912    col.insert("media-top-margin".try_into().unwrap(), IppValue::Integer(0));
913    col.insert(
914        "media-bottom-margin".try_into().unwrap(),
915        IppValue::Integer(0),
916    );
917    col.insert(
918        "media-left-margin".try_into().unwrap(),
919        IppValue::Integer(side_margin_hmm),
920    );
921    col.insert(
922        "media-right-margin".try_into().unwrap(),
923        IppValue::Integer(side_margin_hmm),
924    );
925    IppValue::Collection(col)
926}
927
928/// Unprintable border on each side of a sheet `width_hmm` wide, in hundredths
929/// of a millimetre.
930///
931/// A fixed printhead narrower than the media leaves stock it physically cannot
932/// mark. Reporting that as a hard margin is what lets a client compose into the
933/// imageable area — the alternative, claiming the full width, has the client
934/// lay artwork edge to edge and the driver crop the middle out of a finished
935/// page, which is how a barcode loses its quiet zone.
936///
937/// Split evenly: the media runs centred under the head.
938pub(crate) fn side_margin_hmm(cfg: &crate::printer::PrinterConfig, width_hmm: i32) -> i32 {
939    if cfg.dpi <= 0 || cfg.printhead_width_dots == 0 {
940        return 0;
941    }
942    // Printhead width in hundredths of a millimetre.
943    let head_hmm = (cfg.printhead_width_dots as i64 * 2540 / cfg.dpi as i64) as i32;
944    ((width_hmm - head_hmm).max(0)) / 2
945}
946
947/// The configured custom-size bounds, if the printer advertises any.
948///
949/// Both ends must be non-zero and `max` must not be under `min` — a half-filled
950/// or inverted range would otherwise emit an attribute clients can't satisfy.
951fn custom_media_range(cfg: &crate::printer::PrinterConfig) -> Option<([i32; 2], [i32; 2])> {
952    let (min, max) = (cfg.media_size_min, cfg.media_size_max);
953    let sane = min.iter().all(|&v| v > 0)
954        && max.iter().all(|&v| v > 0)
955        && max[0] >= min[0]
956        && max[1] >= min[1];
957    sane.then_some((min, max))
958}
959
960/// PWG 5101.1 self-describing name for a custom-size bookend, e.g.
961/// `custom_min_10x10mm_10x10mm`. Metric takes the `custom_` class with `mm`
962/// units; dimensions are hundredths of a millimetre internally.
963fn custom_media_name(bound: &str, size_hmm: [i32; 2]) -> String {
964    let (w, h) = (size_hmm[0] / 100, size_hmm[1] / 100);
965    format!("custom_{bound}_{w}x{h}mm_{w}x{h}mm")
966}
967
968/// Build a `media-size` collection whose dimensions are ranges rather than
969/// fixed integers — the "any size between these" form.
970fn media_size_range_col(min_hmm: [i32; 2], max_hmm: [i32; 2]) -> IppValue {
971    use std::collections::BTreeMap;
972    let mut size = BTreeMap::new();
973    size.insert(
974        "x-dimension".try_into().unwrap(),
975        IppValue::RangeOfInteger {
976            min: min_hmm[0],
977            max: max_hmm[0],
978        },
979    );
980    size.insert(
981        "y-dimension".try_into().unwrap(),
982        IppValue::RangeOfInteger {
983            min: min_hmm[1],
984            max: max_hmm[1],
985        },
986    );
987    IppValue::Collection(size)
988}
989
990/// Build a bare `media-size` collection (x/y dimensions only) for
991/// `media-size-supported`.
992fn media_size_col(size_hmm: [i32; 2]) -> IppValue {
993    use std::collections::BTreeMap;
994    let mut size = BTreeMap::new();
995    size.insert(
996        "x-dimension".try_into().unwrap(),
997        IppValue::Integer(size_hmm[0]),
998    );
999    size.insert(
1000        "y-dimension".try_into().unwrap(),
1001        IppValue::Integer(size_hmm[1]),
1002    );
1003    IppValue::Collection(size)
1004}
1005
1006fn uptime_secs() -> u64 {
1007    use std::sync::OnceLock;
1008    use std::time::Instant;
1009    static START: OnceLock<Instant> = OnceLock::new();
1010    START.get_or_init(Instant::now).elapsed().as_secs()
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015
1016    /// The head's unreachable stock is reported as a hard margin, per size.
1017    /// CUPS takes the maximum of the supported set as the page default, so a
1018    /// stray 0 would keep a full-width borderless variant on offer and the
1019    /// artwork would be cropped by the driver instead.
1020    #[test]
1021    fn side_margins_come_from_the_head_width() {
1022        let mut cfg = config_with_range([1000, 1000], [5000, 12000]);
1023        cfg.dpi = 203;
1024        cfg.printhead_width_dots = 384; // 48.04mm
1025                                        // A 50mm roll leaves 0.98mm either side.
1026        assert_eq!(side_margin_hmm(&cfg, 5000), 98);
1027        // Narrower than the head: fully printable.
1028        assert_eq!(side_margin_hmm(&cfg, 4000), 0);
1029        assert_eq!(side_margin_hmm(&cfg, 4804), 0);
1030    }
1031
1032    /// A printer with no usable geometry must not invent margins.
1033    #[test]
1034    fn side_margins_are_zero_without_geometry() {
1035        let mut cfg = config_with_range([1000, 1000], [5000, 12000]);
1036        cfg.dpi = 0;
1037        assert_eq!(side_margin_hmm(&cfg, 5000), 0);
1038        cfg.dpi = 203;
1039        cfg.printhead_width_dots = 0;
1040        assert_eq!(side_margin_hmm(&cfg, 5000), 0);
1041    }
1042    use super::*;
1043    use crate::printer::{PrinterConfig, PrinterRecord};
1044
1045    fn config_with_range(min: [i32; 2], max: [i32; 2]) -> PrinterConfig {
1046        PrinterConfig {
1047            name: "p".into(),
1048            display_name: String::new(),
1049            driver_name: "t".into(),
1050            make_and_model: "Test".into(),
1051            device_id: String::new(),
1052            device_uri: "mock://x".into(),
1053            dpi: 203,
1054            printhead_width_dots: 384,
1055            media_names: vec!["om_40x30mm_40x30mm".into()],
1056            media_sizes: vec![[4000, 3000]],
1057            media_size_min: min,
1058            media_size_max: max,
1059            darkness: 50,
1060            document_formats: vec![],
1061        }
1062    }
1063
1064    fn attrs_text(cfg: PrinterConfig) -> String {
1065        let record = PrinterRecord::new(cfg);
1066        let resp = get_printer_attributes(IppVersion::v2_0(), 1, &record, "localhost", 8631, None)
1067            .unwrap();
1068        format!("{:?}", resp.attributes())
1069    }
1070
1071    /// Bounds set: the range entry and both PWG bookend names must appear, or a
1072    /// desktop dialog has no way to offer a Custom size.
1073    #[test]
1074    fn custom_range_is_advertised_when_bounds_set() {
1075        let text = attrs_text(config_with_range([1000, 1000], [5000, 12000]));
1076        assert!(text.contains("custom_min_10x10mm"), "min bookend missing");
1077        assert!(text.contains("custom_max_50x120mm"), "max bookend missing");
1078        assert!(
1079            text.contains("RangeOfInteger"),
1080            "media-size-supported has no range entry"
1081        );
1082    }
1083
1084    /// Unset bounds must leave the attribute set exactly as before — a printer
1085    /// with fixed media should not suddenly claim custom support.
1086    #[test]
1087    fn no_custom_range_when_bounds_unset() {
1088        let text = attrs_text(config_with_range([0, 0], [0, 0]));
1089        assert!(!text.contains("custom_min"));
1090        assert!(!text.contains("custom_max"));
1091    }
1092
1093    /// Half-filled or inverted bounds are rejected rather than emitted, since a
1094    /// client cannot satisfy a range whose max is below its min.
1095    #[test]
1096    fn malformed_bounds_are_ignored() {
1097        for (min, max) in [
1098            ([1000, 1000], [0, 0]),
1099            ([0, 0], [5000, 5000]),
1100            ([5000, 5000], [1000, 1000]),
1101            ([1000, 6000], [5000, 5000]),
1102        ] {
1103            assert!(
1104                custom_media_range(&config_with_range(min, max)).is_none(),
1105                "{min:?}..{max:?} should be rejected"
1106            );
1107        }
1108    }
1109
1110    /// Look up one printer attribute by name.
1111    fn printer_attr(cfg: PrinterConfig, name: &str) -> IppValue {
1112        let record = PrinterRecord::new(cfg);
1113        let resp = get_printer_attributes(IppVersion::v2_0(), 1, &record, "localhost", 8631, None)
1114            .unwrap();
1115        let value = resp
1116            .attributes()
1117            .groups_of(DelimiterTag::PrinterAttributes)
1118            .flat_map(|g| g.attributes().values())
1119            .find(|a| a.name().as_ref() == name)
1120            .unwrap_or_else(|| panic!("{name} missing"))
1121            .value()
1122            .clone();
1123        value
1124    }
1125
1126    /// The x-dimension of the `media-col` entry carrying `size_name`.
1127    fn x_dimension_of(db: &IppValue, size_name: &str) -> Option<i32> {
1128        db.into_iter().find_map(|entry| {
1129            let IppValue::Collection(col) = entry else {
1130                return None;
1131            };
1132            let named = col
1133                .iter()
1134                .any(|(k, v)| k.as_ref() == "media-size-name" && v.to_string() == size_name);
1135            if !named {
1136                return None;
1137            }
1138            let IppValue::Collection(size) =
1139                col.iter().find(|(k, _)| k.as_ref() == "media-size")?.1
1140            else {
1141                return None;
1142            };
1143            match size.iter().find(|(k, _)| k.as_ref() == "x-dimension")?.1 {
1144                IppValue::Integer(v) => Some(*v),
1145                _ => None,
1146            }
1147        })
1148    }
1149
1150    /// Each custom bookend must carry its own bounds in media-col-database.
1151    /// Zipping the full keyword list against the size list ran them past the
1152    /// end of the size list and handed them the default, so the collection
1153    /// contradicted the dimensions in the name beside it.
1154    #[test]
1155    fn custom_bookends_carry_their_own_size() {
1156        let db = printer_attr(
1157            config_with_range([1000, 1000], [5000, 12000]),
1158            "media-col-database",
1159        );
1160        assert_eq!(
1161            x_dimension_of(&db, "custom_min_10x10mm_10x10mm"),
1162            Some(1000),
1163            "min bookend not paired with its own size"
1164        );
1165        assert_eq!(
1166            x_dimension_of(&db, "custom_max_50x120mm_50x120mm"),
1167            Some(5000),
1168            "max bookend not paired with its own size"
1169        );
1170        // The enumerated entry must be untouched by the change.
1171        assert_eq!(x_dimension_of(&db, "om_40x30mm_40x30mm"), Some(4000));
1172    }
1173
1174    #[test]
1175    fn custom_name_uses_pwg_metric_form() {
1176        assert_eq!(
1177            custom_media_name("min", [1000, 1500]),
1178            "custom_min_10x15mm_10x15mm"
1179        );
1180    }
1181}