synta-certificate 0.2.6

X.509 certificate structures for synta ASN.1 library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
//! Fluent builders for RFC 2634 Extended Security Services (ESS) structures.
//!
//! Three builders are provided:
//!
//! | Builder | Structure | RFC §|
//! |---------|-----------|------|
//! | [`SigningCertificateBuilder`] | `SigningCertificate` | 5.4 |
//! | [`ReceiptRequestBuilder`] | `ReceiptRequest` | 2.7 |
//! | [`ESSSecurityLabelBuilder`] | `ESSSecurityLabel` | 3.2 |
//!
//! # Example — `SigningCertificate`
//!
//! ```rust,ignore
//! use synta_certificate::SigningCertificateBuilder;
//!
//! // SHA-1 hash of the signer's certificate DER
//! let cert_sha1 = [0u8; 20];
//! let sc_der = SigningCertificateBuilder::new()
//!     .add_cert_id(&cert_sha1, None)
//!     .build()
//!     .unwrap();
//! ```
//!
//! # Example — `ReceiptRequest`
//!
//! ```rust,ignore
//! use synta_certificate::ReceiptRequestBuilder;
//!
//! let rr_der = ReceiptRequestBuilder::new()
//!     .signed_content_identifier(b"\x01\x02\x03\x04")
//!     .receipts_from_all()
//!     .add_receipt_to_email("receipts@example.com")
//!     .build()
//!     .unwrap();
//! ```

use crate::ext_builder::encode_sequence;
use synta::tag::TAG_SET;
use synta::traits::Encode;

use crate::ess_types::{
    AllOrFirstTier, ESSCertID, ESSPrivacyMark, ReceiptsFrom, SecurityClassification,
    SecurityPolicyIdentifier, ALL_OR_FIRST_TIER_ALL_RECEIPTS,
    ALL_OR_FIRST_TIER_FIRST_TIER_RECIPIENTS,
};
use crate::GeneralNameSpec;

// ── SigningCertificateBuilder ─────────────────────────────────────────────────

/// Fluent builder for a `SigningCertificate` DER structure (RFC 2634 §5.4).
///
/// `SigningCertificate` is a CMS signed attribute that identifies the signer's
/// certificate by its SHA-1 hash and optionally by its issuer/serial number.
///
/// Add certificate IDs with [`add_cert_id`](Self::add_cert_id) and optional
/// certificate policies with [`add_policy`](Self::add_policy).
#[derive(Debug, Default)]
pub struct SigningCertificateBuilder {
    /// Pre-encoded `ESSCertID` TLVs accumulated as raw DER bytes.
    certs_encoded: Vec<u8>,
    /// Pre-encoded `PolicyInformation` TLVs accumulated as raw DER bytes.
    policies_encoded: Vec<u8>,
    /// First error encountered.
    error: Option<String>,
}

impl SigningCertificateBuilder {
    /// Create a new, empty `SigningCertificateBuilder`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a certificate entry identified by its SHA-1 hash.
    ///
    /// `cert_hash` is the 20-byte SHA-1 hash of the complete DER-encoded
    /// certificate (the entire `Certificate` structure bytes).
    ///
    /// `issuer_serial_der` is an optional pre-encoded `IssuerSerial` SEQUENCE
    /// DER TLV.  Pass `None` to omit the issuer/serial field.
    pub fn add_cert_id(mut self, cert_hash: &[u8], issuer_serial_der: Option<&[u8]>) -> Self {
        if self.error.is_some() {
            return self;
        }
        let hash_ref = synta::OctetStringRef::new(cert_hash);

        // If an IssuerSerial DER blob was provided, validate it by decoding once,
        // then use the decoded value directly when building ESSCertID.
        // (Keeping the owned Vec alive so the borrowed IssuerSerial can reference it.)
        let issuer_serial_bytes = issuer_serial_der.map(|b| b.to_vec());
        let cert_id_der = {
            let issuer_serial_decoded: Option<crate::ess_types::IssuerSerial<'_>> =
                if let Some(ref bytes) = issuer_serial_bytes {
                    match synta::Decoder::new(bytes, synta::Encoding::Der)
                        .decode::<crate::ess_types::IssuerSerial<'_>>()
                    {
                        Ok(is) => Some(is),
                        Err(e) => {
                            self.error = Some(format!("IssuerSerial decode failed: {e}"));
                            return self;
                        }
                    }
                } else {
                    None
                };

            let cert_id = ESSCertID {
                cert_hash: hash_ref,
                issuer_serial: issuer_serial_decoded,
            };
            let mut enc = synta::Encoder::new(synta::Encoding::Der);
            match cert_id.encode(&mut enc).and_then(|()| enc.finish()) {
                Ok(b) => b,
                Err(e) => {
                    self.error = Some(format!("ESSCertID DER encoding failed: {e}"));
                    return self;
                }
            }
        };

        self.certs_encoded.extend_from_slice(&cert_id_der);
        self
    }

    /// Add a certificate policy OID to the optional `policies` field.
    ///
    /// `policy_oid` is the policy OID as a `&[u32]` component slice.  No
    /// qualifiers are added (pass policy OID and rely on defaults per RFC 3280).
    pub fn add_policy(mut self, policy_oid: &[u32]) -> Self {
        if self.error.is_some() {
            return self;
        }
        let oid = match synta::ObjectIdentifier::new(policy_oid) {
            Ok(o) => o,
            Err(e) => {
                self.error = Some(format!("invalid policy OID: {e}"));
                return self;
            }
        };
        let pi = crate::PolicyInformation {
            policy_identifier: oid,
            policy_qualifiers: None,
        };
        let mut enc = synta::Encoder::new(synta::Encoding::Der);
        match pi.encode(&mut enc).and_then(|()| enc.finish()) {
            Ok(bytes) => self.policies_encoded.extend_from_slice(&bytes),
            Err(e) => {
                self.error = Some(format!("PolicyInformation DER encoding failed: {e}"));
            }
        }
        self
    }

    /// Build the DER-encoded `SigningCertificate` SEQUENCE.
    ///
    /// Returns `Err` if no certificate IDs were added or if DER encoding fails.
    pub fn build(self) -> Result<Vec<u8>, String> {
        if let Some(e) = self.error {
            return Err(e);
        }
        if self.certs_encoded.is_empty() {
            return Err("at least one certificate ID is required".to_string());
        }

        // Build `SigningCertificate ::= SEQUENCE { certs SEQUENCE OF ESSCertID,
        //                                          policies SEQUENCE OF PolicyInformation OPTIONAL }`
        // by wrapping the pre-encoded TLVs in SEQUENCE containers.
        let mut content = Vec::new();
        content.extend_from_slice(&encode_sequence(self.certs_encoded));
        if !self.policies_encoded.is_empty() {
            content.extend_from_slice(&encode_sequence(self.policies_encoded));
        }
        Ok(encode_sequence(content))
    }
}

// ── ReceiptRequestBuilder ─────────────────────────────────────────────────────

/// Fluent builder for a `ReceiptRequest` DER structure (RFC 2634 §2.7).
///
/// A `ReceiptRequest` is a CMS signed attribute that requests a signed receipt.
/// Required fields: `signed_content_identifier` and one `receipts_from_*` call.
#[derive(Debug, Default)]
pub struct ReceiptRequestBuilder {
    /// `signedContentIdentifier` OCTET STRING value bytes.
    signed_content_id: Option<Vec<u8>>,
    /// Pre-encoded `ReceiptsFrom` CHOICE TLV bytes.
    receipts_from: Option<Vec<u8>>,
    /// Pre-encoded `GeneralNames` SEQUENCE TLVs for the `receiptsTo` list.
    receipts_to_encoded: Vec<u8>,
    /// First error encountered.
    error: Option<String>,
}

impl ReceiptRequestBuilder {
    /// Create a new, empty `ReceiptRequestBuilder`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the `signedContentIdentifier` (an OCTET STRING value).
    pub fn signed_content_identifier(mut self, id: &[u8]) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.signed_content_id = Some(id.to_vec());
        self
    }

    /// Request receipts from all recipients (`allReceipts [0]`).
    pub fn receipts_from_all(self) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.encode_receipts_from(ReceiptsFrom::AllOrFirstTier(AllOrFirstTier::from(
            ALL_OR_FIRST_TIER_ALL_RECEIPTS,
        )))
    }

    /// Request receipts from first-tier recipients only (`firstTierRecipients [0]`).
    pub fn receipts_from_first_tier(self) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.encode_receipts_from(ReceiptsFrom::AllOrFirstTier(AllOrFirstTier::from(
            ALL_OR_FIRST_TIER_FIRST_TIER_RECIPIENTS,
        )))
    }

    fn encode_receipts_from(mut self, rf: ReceiptsFrom<'_>) -> Self {
        let mut enc = synta::Encoder::new(synta::Encoding::Der);
        match rf.encode(&mut enc).and_then(|()| enc.finish()) {
            Ok(bytes) => self.receipts_from = Some(bytes),
            Err(e) => self.error = Some(format!("ReceiptsFrom DER encoding failed: {e}")),
        }
        self
    }

    /// Add an email address to the `receiptsTo` list.
    ///
    /// Each entry in `receiptsTo` is a `GeneralNames` (SEQUENCE OF GeneralName).
    /// This convenience method wraps a single RFC 822 name in a `GeneralNames`
    /// SEQUENCE.
    pub fn add_receipt_to_email(mut self, email: &str) -> Self {
        if self.error.is_some() {
            return self;
        }
        let spec = GeneralNameSpec::rfc822(email);
        let gn = match spec.to_general_name() {
            Ok(gn) => gn,
            Err(e) => {
                self.error = Some(format!("GeneralName error: {e}"));
                return self;
            }
        };
        let mut enc = synta::Encoder::new(synta::Encoding::Der);
        match gn.encode(&mut enc).and_then(|()| enc.finish()) {
            Ok(gn_bytes) => {
                // Wrap in a GeneralNames SEQUENCE.
                let gn_seq = encode_sequence(gn_bytes);
                self.receipts_to_encoded.extend_from_slice(&gn_seq);
            }
            Err(e) => {
                self.error = Some(format!("GeneralName DER encoding failed: {e}"));
            }
        }
        self
    }

    /// Add a pre-encoded `GeneralNames` DER TLV to the `receiptsTo` list.
    pub fn add_receipt_to_raw(mut self, general_names_der: &[u8]) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.receipts_to_encoded
            .extend_from_slice(general_names_der);
        self
    }

    /// Build the DER-encoded `ReceiptRequest` SEQUENCE.
    ///
    /// Returns `Err` if required fields are missing or DER encoding fails.
    pub fn build(self) -> Result<Vec<u8>, String> {
        if let Some(e) = self.error {
            return Err(e);
        }

        let sci_bytes = self
            .signed_content_id
            .ok_or_else(|| "signed_content_identifier is required".to_string())?;
        let rf_bytes = self.receipts_from.ok_or_else(|| {
            "receipts_from (call receipts_from_all or receipts_from_first_tier) is required"
                .to_string()
        })?;
        if self.receipts_to_encoded.is_empty() {
            return Err(
                "at least one receiptsTo entry is required (use add_receipt_to_email or add_receipt_to_raw)"
                    .to_string(),
            );
        }

        // Encode signedContentIdentifier as OCTET STRING TLV.
        let sci_os = synta::OctetString::new(sci_bytes);
        let mut sci_enc = synta::Encoder::new(synta::Encoding::Der);
        sci_os
            .encode(&mut sci_enc)
            .map_err(|e| format!("signedContentIdentifier encode failed: {e}"))?;
        let sci_der = sci_enc
            .finish()
            .map_err(|e| format!("signedContentIdentifier finish failed: {e}"))?;

        // Build ReceiptRequest ::= SEQUENCE {
        //     signedContentIdentifier ContentIdentifier,   -- OCTET STRING
        //     receiptsFrom            ReceiptsFrom,        -- CHOICE (pre-encoded)
        //     receiptsTo              ReceiptsTo }         -- SEQUENCE OF GeneralNames
        // receipts_to_encoded contains pre-encoded GeneralNames SEQUENCE TLVs;
        // wrap them in an outer SEQUENCE to form the ReceiptsTo SEQUENCE OF.
        let receipts_to_der = encode_sequence(self.receipts_to_encoded);

        let mut content = Vec::new();
        content.extend_from_slice(&sci_der);
        content.extend_from_slice(&rf_bytes);
        content.extend_from_slice(&receipts_to_der);

        Ok(encode_sequence(content))
    }
}

// ── ESSSecurityLabelBuilder ───────────────────────────────────────────────────

/// Fluent builder for an `ESSSecurityLabel` DER SET (RFC 2634 §3.2).
///
/// An `ESSSecurityLabel` is a CMS signed attribute that carries an information
/// security label on a signed message.  The `security_policy_identifier` OID
/// is required; all other fields are optional.
#[derive(Debug, Default)]
pub struct ESSSecurityLabelBuilder {
    /// `securityPolicyIdentifier` OID components.
    policy_oid: Option<Vec<u32>>,
    /// `securityClassification` value (0..32767).
    classification: Option<u16>,
    /// `privacyMark` string value.
    privacy_mark: Option<String>,
    /// `true` if `privacy_mark` was set via `privacy_mark_printable` (PrintableString),
    /// `false` if via `privacy_mark_utf8` (UTF8String).
    privacy_mark_is_printable: bool,
    /// Pre-encoded `SecurityCategory` TLVs accumulated as raw bytes for the SET.
    categories_encoded: Vec<u8>,
    /// First error encountered.
    error: Option<String>,
}

impl ESSSecurityLabelBuilder {
    /// Create a new, empty `ESSSecurityLabelBuilder`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the `securityPolicyIdentifier` OID.
    ///
    /// `policy_oid` is the OID as a `&[u32]` component slice.  This field is
    /// required.
    pub fn security_policy(mut self, policy_oid: &[u32]) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.policy_oid = Some(policy_oid.to_vec());
        self
    }

    /// Set the optional `securityClassification` value.
    ///
    /// Standard values (RFC 2634 §3.2):
    /// - `SecurityClassification::UNMARKED` = 0
    /// - `SecurityClassification::UNCLASSIFIED` = 1
    /// - `SecurityClassification::RESTRICTED` = 2
    /// - `SecurityClassification::CONFIDENTIAL` = 3
    /// - `SecurityClassification::SECRET` = 4
    /// - `SecurityClassification::TOP_SECRET` = 5
    ///
    /// Values above 32767 are rejected: RFC 2634 §3.2 constrains this field to
    /// `INTEGER (0..32767)`.
    pub fn classification(mut self, value: u16) -> Self {
        if self.error.is_some() {
            return self;
        }
        if value > 32767 {
            self.error = Some(format!(
                "security classification value {} exceeds RFC 2634 maximum of 32767",
                value
            ));
            return self;
        }
        self.classification = Some(value);
        self
    }

    /// Set the optional `privacyMark` as a UTF8String.
    ///
    /// The standard also allows PrintableString; use [`privacy_mark_printable`](Self::privacy_mark_printable)
    /// for that variant.
    pub fn privacy_mark_utf8(mut self, mark: &str) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.privacy_mark = Some(mark.to_string());
        self.privacy_mark_is_printable = false;
        self
    }

    /// Set the optional `privacyMark` as a PrintableString.
    ///
    /// The DER output will carry tag `0x13` (PrintableString) rather than
    /// `0x0C` (UTF8String).  The content is validated against the
    /// PrintableString character set at call time.
    pub fn privacy_mark_printable(mut self, mark: &str) -> Self {
        if self.error.is_some() {
            return self;
        }
        // Validate that the string is valid PrintableString content.
        let printable_chars = mark
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || " '()+,-./:=?".contains(c));
        if !printable_chars {
            self.error = Some(format!(
                "privacy mark '{}' contains characters not allowed in PrintableString",
                mark
            ));
            return self;
        }
        self.privacy_mark = Some(mark.to_string());
        self.privacy_mark_is_printable = true;
        self
    }

    /// Add a pre-encoded `SecurityCategory` DER TLV to the security categories field.
    ///
    /// Each call appends one `SecurityCategory SEQUENCE` to the `securityCategories`
    /// `SET OF SecurityCategory` field (RFC 2634 §3.2).
    ///
    /// `security_category_der` must be a valid DER-encoded `SecurityCategory` TLV.
    pub fn add_security_category_raw(mut self, security_category_der: &[u8]) -> Self {
        if self.error.is_some() {
            return self;
        }
        self.categories_encoded
            .extend_from_slice(security_category_der);
        self
    }

    /// Build the DER-encoded `ESSSecurityLabel` SET.
    ///
    /// Returns `Err` if `security_policy` was not set or if DER encoding fails.
    pub fn build(self) -> Result<Vec<u8>, String> {
        if let Some(e) = self.error {
            return Err(e);
        }

        let policy_comps = self
            .policy_oid
            .ok_or_else(|| "security_policy (OID) is required".to_string())?;
        let policy_oid: SecurityPolicyIdentifier = synta::ObjectIdentifier::new(&policy_comps)
            .map_err(|e| format!("invalid security policy OID: {e}"))?;

        let security_classification = self
            .classification
            .map(SecurityClassification::new_unchecked);

        let privacy_mark_str = self.privacy_mark;
        let privacy_mark_is_printable = self.privacy_mark_is_printable;
        let privacy_mark: Option<ESSPrivacyMark<'_>> = if let Some(ref s) = privacy_mark_str {
            if privacy_mark_is_printable {
                // Content was validated as PrintableString in the setter.
                match synta::PrintableStringRef::new(s) {
                    Ok(ps) => Some(ESSPrivacyMark::PString(ps)),
                    Err(e) => {
                        return Err(format!("privacyMark PrintableString encoding failed: {e}"))
                    }
                }
            } else {
                Some(ESSPrivacyMark::Utf8String(synta::Utf8StringRef::new(s)))
            }
        } else {
            None
        };

        // ESSSecurityLabel is a SET. We build its content from pre-encoded
        // fields and wrap in a SET TLV (tag 0x31) rather than fighting with
        // lifetime constraints in the generated struct.

        // Encode securityPolicyIdentifier OID.
        let mut pol_enc = synta::Encoder::new(synta::Encoding::Der);
        use synta::traits::Encode;
        policy_oid
            .encode(&mut pol_enc)
            .map_err(|e| format!("securityPolicyIdentifier encode failed: {e}"))?;
        let pol_der = pol_enc
            .finish()
            .map_err(|e| format!("securityPolicyIdentifier finish failed: {e}"))?;

        // Encode optional securityClassification.
        let class_der: Option<Vec<u8>> = if let Some(c) = security_classification {
            let mut enc = synta::Encoder::new(synta::Encoding::Der);
            c.encode(&mut enc)
                .map_err(|e| format!("securityClassification encode failed: {e}"))?;
            Some(
                enc.finish()
                    .map_err(|e| format!("securityClassification finish failed: {e}"))?,
            )
        } else {
            None
        };

        // Encode optional privacyMark.
        let pm_der: Option<Vec<u8>> = if let Some(pm) = privacy_mark {
            let mut enc = synta::Encoder::new(synta::Encoding::Der);
            pm.encode(&mut enc)
                .map_err(|e| format!("privacyMark encode failed: {e}"))?;
            Some(
                enc.finish()
                    .map_err(|e| format!("privacyMark finish failed: {e}"))?,
            )
        } else {
            None
        };

        // Assemble ESSSecurityLabel SET content in ASN.1 declaration order.
        // Note: strict DER (X.690 §11.6) requires SET components to be sorted by
        // tag value (INTEGER 0x02 before OID 0x06), but the synta generated decoder
        // reads fields in declaration order.  Keeping declaration order here ensures
        // the round-trip decoder works; fix both encoder and decoder together if
        // strict canonical ordering is needed in future.
        let mut set_content = Vec::new();
        // 1. securityPolicyIdentifier OBJECT IDENTIFIER (tag 0x06) — required
        set_content.extend_from_slice(&pol_der);
        // 2. securityClassification INTEGER (tag 0x02) — optional
        if let Some(ref c) = class_der {
            set_content.extend_from_slice(c);
        }
        // 3. privacyMark: UTF8String (0x0C) or PrintableString (0x13) — optional
        if let Some(ref pm) = pm_der {
            set_content.extend_from_slice(pm);
        }
        if !self.categories_encoded.is_empty() {
            // Wrap SecurityCategory TLVs in a SET OF (tag 0x31).
            let cat_tag = synta::Tag::universal_constructed(TAG_SET);
            let mut cat_enc = synta::Encoder::new(synta::Encoding::Der);
            cat_enc
                .start_constructed_no_guard(cat_tag)
                .map_err(|e| format!("SecurityCategories SET start failed: {e}"))?;
            cat_enc.write_bytes(&self.categories_encoded);
            cat_enc
                .end_constructed()
                .map_err(|e| format!("SecurityCategories SET end failed: {e}"))?;
            let cat_der = cat_enc
                .finish()
                .map_err(|e| format!("SecurityCategories SET finish failed: {e}"))?;
            set_content.extend_from_slice(&cat_der);
        }

        // Wrap the entire SET content with the SET tag.
        let set_tag = synta::Tag::universal_constructed(TAG_SET);
        let mut enc = synta::Encoder::new(synta::Encoding::Der);
        enc.start_constructed_no_guard(set_tag)
            .map_err(|e| format!("ESSSecurityLabel SET start failed: {e}"))?;
        enc.write_bytes(&set_content);
        enc.end_constructed()
            .map_err(|e| format!("ESSSecurityLabel SET end failed: {e}"))?;
        enc.finish().map_err(|e| format!("DER finish failed: {e}"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn signing_certificate_minimal() {
        let cert_hash = [0xabu8; 20]; // fake SHA-1 hash
        let der = SigningCertificateBuilder::new()
            .add_cert_id(&cert_hash, None)
            .build()
            .expect("build should succeed");

        let decoded =
            crate::ess_types::SigningCertificate::from_der(&der).expect("round-trip decode failed");
        assert_eq!(decoded.certs.len(), 1);
        assert!(decoded.policies.is_none());
    }

    #[test]
    fn signing_certificate_requires_at_least_one_cert() {
        let err = SigningCertificateBuilder::new().build();
        assert!(err.is_err());
    }

    #[test]
    fn receipt_request_all_receipts() {
        let der = ReceiptRequestBuilder::new()
            .signed_content_identifier(b"\x01\x02\x03\x04")
            .receipts_from_all()
            .add_receipt_to_email("receipts@example.com")
            .build()
            .expect("build should succeed");

        let decoded =
            crate::ess_types::ReceiptRequest::from_der(&der).expect("round-trip decode failed");
        assert!(!decoded.receipts_to.is_empty());
    }

    #[test]
    fn receipt_request_missing_sci_returns_error() {
        let err = ReceiptRequestBuilder::new().receipts_from_all().build();
        assert!(err.is_err());
        assert!(err.unwrap_err().contains("signed_content_identifier"));
    }

    #[test]
    fn receipt_request_missing_from_returns_error() {
        let err = ReceiptRequestBuilder::new()
            .signed_content_identifier(b"\x01\x02")
            .build();
        assert!(err.is_err());
        assert!(err.unwrap_err().contains("receipts_from"));
    }

    #[test]
    fn ess_security_label_minimal() {
        // Use a custom policy OID
        let policy = &[1u32, 3, 6, 1, 5, 5, 7, 13, 1];
        let der = ESSSecurityLabelBuilder::new()
            .security_policy(policy)
            .build()
            .expect("build should succeed");

        let decoded =
            crate::ess_types::ESSSecurityLabel::from_der(&der).expect("round-trip decode failed");
        assert!(decoded.security_classification.is_none());
        assert!(decoded.privacy_mark.is_none());
    }

    #[test]
    fn ess_security_label_with_classification() {
        let policy = &[1u32, 3, 6, 1, 5, 5, 7, 13, 1];
        let der = ESSSecurityLabelBuilder::new()
            .security_policy(policy)
            .classification(SecurityClassification::SECRET)
            .privacy_mark_utf8("SECRET")
            .build()
            .expect("build with classification should succeed");

        let decoded =
            crate::ess_types::ESSSecurityLabel::from_der(&der).expect("round-trip decode failed");
        assert_eq!(
            decoded.security_classification.map(|c| c.get()),
            Some(SecurityClassification::SECRET)
        );
    }

    #[test]
    fn receipt_request_empty_receipts_to_returns_error() {
        // M6: at least one receiptsTo entry must be present
        let err = ReceiptRequestBuilder::new()
            .signed_content_identifier(b"\x01\x02")
            .receipts_from_all()
            .build();
        assert!(err.is_err());
        assert!(err.unwrap_err().contains("receiptsTo"));
    }

    #[test]
    fn ess_security_label_requires_policy() {
        let err = ESSSecurityLabelBuilder::new().build();
        assert!(err.is_err());
        assert!(err.unwrap_err().contains("security_policy"));
    }

    #[test]
    fn ess_security_label_classification_out_of_range() {
        // M1: values above 32767 must be rejected
        let policy = &[1u32, 3, 6, 1, 5, 5, 7, 13, 1];
        let err = ESSSecurityLabelBuilder::new()
            .security_policy(policy)
            .classification(32768)
            .build();
        assert!(err.is_err());
        assert!(err.unwrap_err().contains("32767"));
    }

    #[test]
    fn ess_security_label_privacy_mark_printable_encodes_correctly() {
        // H1: privacy_mark_printable must produce a PrintableString-tagged field
        let policy = &[1u32, 3, 6, 1, 5, 5, 7, 13, 1];
        let der = ESSSecurityLabelBuilder::new()
            .security_policy(policy)
            .privacy_mark_printable("SECRET")
            .build()
            .expect("build with PrintableString mark should succeed");

        let decoded =
            crate::ess_types::ESSSecurityLabel::from_der(&der).expect("round-trip decode failed");
        assert!(
            matches!(
                decoded.privacy_mark,
                Some(crate::ess_types::ESSPrivacyMark::PString(_))
            ),
            "expected PString variant, got {:?}",
            decoded.privacy_mark
        );
    }
}