Skip to main content

cqrs_es_crypto_derive/
lib.rs

1//! Proc-macro crate for `#[derive(PiiCodec)]`.
2//!
3//! Generates a `{Name}PiiCodec` struct and a [`PiiEventCodec`] implementation
4//! from an annotated event enum.
5//!
6//! # Usage
7//!
8//! ```rust,ignore
9//! use cqrs_es_crypto::PiiCodec;
10//!
11//! #[derive(PiiCodec)]
12//! enum MyEvent {
13//!     #[pii(event_type = "SensitiveEvent")]
14//!     SensitiveEvent {
15//!         #[pii(subject)]   subject_id: uuid::Uuid,
16//!         #[pii(plaintext)] tag: String,
17//!         #[pii(secret)]    secret: String,
18//!     },
19//!     PlainEvent { data: String },
20//! }
21//! // Generates: pub struct MyEventPiiCodec;
22//! // + impl PiiEventCodec for MyEventPiiCodec { ... }
23//! ```
24//!
25//! # Field types and redaction
26//!
27//! `#[pii(secret)]` fields are redacted to a per-type default when the
28//! subject's DEK has been deleted. The defaults are:
29//!
30//! | Field type            | Redaction value | Notes                                       |
31//! |-----------------------|-----------------|---------------------------------------------|
32//! | `String`              | `"[redacted]"`  | fixed; not user-overridable                 |
33//! | `Option<_>`           | `null`          | fixed; not user-overridable                 |
34//! | `serde_json::Value`   | `{}`            | fixed; not user-overridable                 |
35//! | `Vec<_>`              | `[]`            | fixed; not user-overridable                 |
36//! | `chrono::NaiveDate`   | `"0000-01-01"`  | requires the `chrono` feature; overridable  |
37//!
38//! For types that don't have an inferred default (or to override one that
39//! does support overrides) supply an explicit redaction value:
40//!
41//! ```rust,ignore
42//! #[derive(PiiCodec)]
43//! enum MyEvent {
44//!     #[pii(event_type = "PersonCaptured")]
45//!     PersonCaptured {
46//!         #[pii(subject)]                       subject_id: uuid::Uuid,
47//!         #[pii(secret)]                        name: String,
48//!         // Default `"0000-01-01"` (requires the `chrono` feature):
49//!         #[pii(secret)]                        dob: chrono::NaiveDate,
50//!         // Custom override:
51//!         #[pii(secret, redact = "1900-01-01")] dod: chrono::NaiveDate,
52//!     },
53//! }
54//! ```
55
56mod model;
57mod parse;
58
59use zyn::{
60    ToTokens as _,
61    proc_macro2::{Delimiter, Group, Ident, Literal, Span, TokenStream, TokenTree},
62    syn::LitStr,
63};
64
65use crate::model::{PiiVariantModel, RedactValue};
66
67// ── Helpers ───────────────────────────────────────────────────────────────────
68
69/// Precomputed data for a single plaintext field: the JSON key as a string
70/// literal and the local variable name that will hold the extracted value.
71///
72/// For a field named `person_ref`:
73/// - `name_str` → `"person_ref"` (emitted as a token wherever the JSON key is needed)
74/// - `binding`  → `person_ref_str` (the local `let` variable in the generated arm)
75struct PlaintextFieldData {
76    name_str: LitStr,
77    binding: Ident,
78}
79
80fn plaintext_field_data(variant: &PiiVariantModel, span: Span) -> Vec<PlaintextFieldData> {
81    variant
82        .plaintext_fields()
83        .map(|f| PlaintextFieldData {
84            name_str: LitStr::new(&f.ident.to_string(), span),
85            binding: zyn::format_ident!("{}_str", f.ident),
86        })
87        .collect()
88}
89
90/// Convert a [`model::RedactValue`] into the `proc_macro2` tokens that represent
91/// the JSON literal to emit for that value (e.g. `"[redacted]"`, `null`, `{}`).
92///
93/// These tokens are inserted directly into the body of a `serde_json::json!`
94/// invocation generated by [`redact_arm`].
95fn redact_value_to_tokens(rv: &RedactValue, span: Span) -> TokenStream {
96    match rv {
97        RedactValue::Literal(s) => {
98            std::iter::once(TokenTree::Literal(Literal::string(s))).collect()
99        }
100        RedactValue::Null => std::iter::once(TokenTree::Ident(Ident::new("null", span))).collect(),
101        RedactValue::EmptyObject => std::iter::once(TokenTree::Group(Group::new(
102            Delimiter::Brace,
103            TokenStream::new(),
104        )))
105        .collect(),
106        RedactValue::EmptyArray => std::iter::once(TokenTree::Group(Group::new(
107            Delimiter::Bracket,
108            TokenStream::new(),
109        )))
110        .collect(),
111    }
112}
113
114/// Precomputed data for a single secret field: the JSON key as a string literal
115/// and the token stream representing the redaction placeholder value.
116struct SecretFieldData {
117    name_str: LitStr,
118    /// Tokens to emit in the redacted JSON (e.g. `"[redacted]"`, `null`, `{}`).
119    redact_ts: TokenStream,
120}
121
122fn secret_field_data(variant: &PiiVariantModel, span: Span) -> Vec<SecretFieldData> {
123    variant
124        .secret_fields()
125        .map(|f| SecretFieldData {
126            name_str: LitStr::new(&f.ident.to_string(), span),
127            redact_ts: redact_value_to_tokens(
128                f.redact
129                    .as_ref()
130                    .expect("secret fields always have a RedactValue"),
131                span,
132            ),
133        })
134        .collect()
135}
136
137// ── Elements ──────────────────────────────────────────────────────────────────
138
139/// Generates one match arm for the `classify` method.
140///
141/// For a variant with **multiple** secret fields the PII blob is a JSON object
142/// keyed by field name.  For a variant with exactly **one** secret field the
143/// blob is that field's value directly (no wrapping object).
144///
145/// Plaintext fields (e.g. `person_ref`) are extracted into local `*_str`
146/// variables so the `move` closure in `build_encrypted_payload` can capture
147/// them.
148#[zyn::element]
149fn classify_arm(variant: PiiVariantModel) -> zyn::TokenStream {
150    let span = Span::call_site();
151
152    let event_type = LitStr::new(&variant.event_type, span);
153    let sentinel = LitStr::new(&variant.sentinel, span);
154    let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
155
156    let plaintext = plaintext_field_data(variant, span);
157
158    // Secret field JSON keys — used for multi-secret bundling.
159    let secret_strs: Vec<LitStr> = variant
160        .secret_fields()
161        .map(|f| LitStr::new(&f.ident.to_string(), span))
162        .collect();
163
164    let is_single = variant.is_single_secret();
165
166    // For the single-secret case: the one secret field's JSON key.
167    let single_secret_str = is_single.then(|| {
168        LitStr::new(
169            &variant.secret_fields().next().unwrap().ident.to_string(),
170            span,
171        )
172    });
173
174    zyn::zyn! {
175        {{ event_type }} => {
176            let __key = {{ event_type }};
177            let __subject_id_str = event.payload[__key][{{ subject_str }}]
178                .as_str()?.to_string();
179            let __subject_id = ::uuid::Uuid::parse_str(&__subject_id_str).ok()?;
180
181            // Extract each plaintext field into a named local variable so it
182            // can be moved into the build_encrypted_payload closure below.
183            // Clone the JSON value as-is so non-string plaintext fields
184            // (numbers, nulls, bools) survive the round-trip; coercing via
185            // `as_str().unwrap_or("")` would corrupt them to the empty string.
186            @for (pt in plaintext.iter()) {
187                let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
188            }
189
190            // Build the plaintext PII blob.
191            // Single secret → use the field value directly.
192            // Multiple secrets → bundle into a JSON object keyed by field name.
193            @if (is_single) {
194                let __plaintext_pii =
195                    event.payload[__key][{{ single_secret_str.as_ref().unwrap() }}].clone();
196            } @else {
197                let __inner = event.payload[__key].as_object()?;
198                let __plaintext_pii = ::serde_json::json!({
199                    @for (s in secret_strs.iter()) {
200                        {{ s }}: __inner.get({{ s }})
201                            .cloned()
202                            .unwrap_or(::serde_json::Value::Null),
203                    }
204                });
205            }
206
207            ::core::option::Option::Some(::cqrs_es_crypto::PiiFields {
208                subject_id: __subject_id,
209                plaintext_pii: __plaintext_pii,
210                build_encrypted_payload: ::std::boxed::Box::new(move |__sentinel| {
211                    ::serde_json::json!({
212                        {{ event_type }}: {
213                            @for (pt in plaintext.iter()) {
214                                {{ pt.name_str }}: {{ pt.binding }},
215                            }
216                            {{ subject_str }}: __subject_id_str,
217                            {{ sentinel }}: __sentinel.ciphertext_b64,
218                            "nonce": __sentinel.nonce_b64,
219                        }
220                    })
221                }),
222            })
223        }
224    }
225}
226
227/// Generates one match arm for the `extract_encrypted` method.
228///
229/// Returns `None` (via `?`) if:
230/// - the sentinel field is absent — this is a legacy plaintext event and must
231///   be passed through unchanged.
232/// - any field is missing or malformed.
233#[zyn::element]
234fn extract_arm(variant: PiiVariantModel) -> zyn::TokenStream {
235    let span = Span::call_site();
236
237    let event_type = LitStr::new(&variant.event_type, span);
238    let sentinel = LitStr::new(&variant.sentinel, span);
239    let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
240
241    zyn::zyn! {
242        {{ event_type }} => {
243            let __key = {{ event_type }};
244            // No sentinel → legacy plaintext event; pass through unchanged.
245            event.payload[__key].get({{ sentinel }})?;
246            let __subject_id = ::uuid::Uuid::parse_str(
247                event.payload[__key][{{ subject_str }}].as_str()?
248            ).ok()?;
249            let __ciphertext = <::base64::engine::general_purpose::GeneralPurpose
250                as ::base64::Engine>::decode(
251                    &::base64::engine::general_purpose::STANDARD,
252                    event.payload[__key][{{ sentinel }}].as_str()?,
253                ).ok()?;
254            let __nonce = <::base64::engine::general_purpose::GeneralPurpose
255                as ::base64::Engine>::decode(
256                    &::base64::engine::general_purpose::STANDARD,
257                    event.payload[__key]["nonce"].as_str()?,
258                ).ok()?;
259            ::core::option::Option::Some(::cqrs_es_crypto::EncryptedPiiExtract {
260                subject_id: __subject_id,
261                ciphertext: __ciphertext,
262                nonce: __nonce,
263            })
264        }
265    }
266}
267
268/// Generates one match arm for the `reconstruct` method.
269///
270/// Reads plaintext fields (including the subject field) from the stored
271/// encrypted-form event, then merges them with the decrypted secret fields
272/// from `plaintext_pii` to produce a fully-reconstructed event payload.
273///
274/// - Multi-secret variants: each secret field is accessed as
275///   `plaintext_pii["field_name"]`.
276/// - Single-secret variants: `plaintext_pii` is used directly as the field
277///   value (it was never wrapped in an object during encryption).
278#[zyn::element]
279fn reconstruct_arm(variant: PiiVariantModel) -> zyn::TokenStream {
280    let span = Span::call_site();
281
282    let event_type = LitStr::new(&variant.event_type, span);
283    let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
284    let plaintext = plaintext_field_data(variant, span);
285
286    let secret_strs: Vec<LitStr> = variant
287        .secret_fields()
288        .map(|f| LitStr::new(&f.ident.to_string(), span))
289        .collect();
290
291    let is_single = variant.is_single_secret();
292
293    let single_secret_str = is_single.then(|| {
294        LitStr::new(
295            &variant.secret_fields().next().unwrap().ident.to_string(),
296            span,
297        )
298    });
299
300    zyn::zyn! {
301        {{ event_type }} => {
302            let __key = {{ event_type }};
303            @for (pt in plaintext.iter()) {
304                let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
305            }
306            let __subject_id_val = event.payload[__key][{{ subject_str }}].clone();
307            ::core::result::Result::Ok(::serde_json::json!({
308                {{ event_type }}: {
309                    @for (pt in plaintext.iter()) {
310                        {{ pt.name_str }}: {{ pt.binding }},
311                    }
312                    {{ subject_str }}: __subject_id_val,
313                    @if (is_single) {
314                        {{ single_secret_str.as_ref().unwrap() }}: plaintext_pii,
315                    } @else {
316                        @for (s in secret_strs.iter()) {
317                            {{ s }}: plaintext_pii[{{ s }}],
318                        }
319                    }
320                }
321            }))
322        }
323    }
324}
325
326/// Generates one match arm for the `redact` method.
327///
328/// Reads plaintext fields (including the subject field) from the stored
329/// encrypted-form event, then emits static redaction placeholder values for
330/// every secret field. The placeholders are derived from each field's
331/// [`model::RedactValue`]:
332///
333/// - `String` → `"[redacted]"`
334/// - `Option<_>` → `null`
335/// - `Value` / `serde_json::Value` → `{}`
336/// - `Vec<_>` → `[]`
337#[zyn::element]
338fn redact_arm(variant: PiiVariantModel) -> zyn::TokenStream {
339    let span = Span::call_site();
340
341    let event_type = LitStr::new(&variant.event_type, span);
342    let subject_str = LitStr::new(&variant.subject_field().ident.to_string(), span);
343    let plaintext = plaintext_field_data(variant, span);
344    let secrets = secret_field_data(variant, span);
345
346    zyn::zyn! {
347        {{ event_type }} => {
348            let __key = {{ event_type }};
349            @for (pt in plaintext.iter()) {
350                let {{ pt.binding }} = event.payload[__key][{{ pt.name_str }}].clone();
351            }
352            let __subject_id_val = event.payload[__key][{{ subject_str }}].clone();
353            ::core::result::Result::Ok(::serde_json::json!({
354                {{ event_type }}: {
355                    @for (pt in plaintext.iter()) {
356                        {{ pt.name_str }}: {{ pt.binding }},
357                    }
358                    {{ subject_str }}: __subject_id_val,
359                    @for (sf in secrets.iter()) {
360                        {{ sf.name_str }}: {{ sf.redact_ts }},
361                    }
362                }
363            }))
364        }
365    }
366}
367
368// ── Derive entry point ────────────────────────────────────────────────────────
369
370/// Derives a [`PiiEventCodec`](::cqrs_es_crypto::PiiEventCodec) implementation
371/// from an annotated event enum.
372///
373/// See the [crate-level documentation](self) for the annotation syntax.
374#[zyn::derive("PiiCodec", attributes(pii))]
375fn pii_codec(
376    #[zyn(input)] ident: zyn::Extract<Ident>,
377    #[zyn(input)] variants: zyn::Variants,
378) -> zyn::TokenStream {
379    let codec_ident = zyn::format_ident!("{}PiiCodec", *ident);
380
381    // Parse and validate `#[pii(...)]` annotations.  Any error is surfaced as
382    // a `compile_error!` at the call site with an accurate source span.
383    let pii_variants: Vec<PiiVariantModel> = match parse::parse_pii_variants(&variants) {
384        Ok(v) => v,
385        Err(e) => return e.into_compile_error().into(),
386    };
387
388    zyn::zyn! {
389        /// [`PiiEventCodec`](::cqrs_es_crypto::PiiEventCodec) implementation
390        /// generated by `#[derive(PiiCodec)]`.
391        pub struct {{ codec_ident }};
392
393        impl ::cqrs_es_crypto::PiiEventCodec for {{ codec_ident }} {
394            fn classify(
395                &self,
396                event: &::cqrs_es::persist::SerializedEvent,
397            ) -> ::core::option::Option<::cqrs_es_crypto::PiiFields> {
398                match event.event_type.as_str() {
399                    @for (v in pii_variants.iter()) {
400                        @classify_arm(variant = v.clone())
401                    }
402                    _ => ::core::option::Option::None,
403                }
404            }
405
406            fn extract_encrypted(
407                &self,
408                event: &::cqrs_es::persist::SerializedEvent,
409            ) -> ::core::option::Option<::cqrs_es_crypto::EncryptedPiiExtract> {
410                match event.event_type.as_str() {
411                    @for (v in pii_variants.iter()) {
412                        @extract_arm(variant = v.clone())
413                    }
414                    _ => ::core::option::Option::None,
415                }
416            }
417
418            fn reconstruct(
419                &self,
420                event: &::cqrs_es::persist::SerializedEvent,
421                plaintext_pii: &::serde_json::Value,
422            ) -> ::core::result::Result<
423                ::serde_json::Value,
424                ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
425            > {
426                match event.event_type.as_str() {
427                    @for (v in pii_variants.iter()) {
428                        @reconstruct_arm(variant = v.clone())
429                    }
430                    _ => ::core::result::Result::Ok(event.payload.clone()),
431                }
432            }
433
434            fn redact(
435                &self,
436                event: &::cqrs_es::persist::SerializedEvent,
437            ) -> ::core::result::Result<
438                ::serde_json::Value,
439                ::std::boxed::Box<dyn ::std::error::Error + Send + Sync>,
440            > {
441                match event.event_type.as_str() {
442                    @for (v in pii_variants.iter()) {
443                        @redact_arm(variant = v.clone())
444                    }
445                    _ => ::core::result::Result::Ok(event.payload.clone()),
446                }
447            }
448        }
449    }
450    .to_token_stream()
451}
452
453// ── Tests ─────────────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use crate::model::{PiiFieldModel, PiiFieldRole, PiiVariantModel, RedactValue};
458
459    use super::*;
460
461    // ── Helpers ───────────────────────────────────────────────────────────────
462
463    fn dummy_input() -> zyn::Input {
464        zyn::parse!("enum TestEvent {}" => zyn::syn::DeriveInput)
465            .unwrap()
466            .into()
467    }
468
469    fn field(name: &str, role: PiiFieldRole) -> PiiFieldModel {
470        PiiFieldModel {
471            ident: zyn::format_ident!("{}", name),
472            role,
473            redact: None,
474        }
475    }
476
477    fn str_secret_field(name: &str) -> PiiFieldModel {
478        PiiFieldModel {
479            ident: zyn::format_ident!("{}", name),
480            role: PiiFieldRole::Secret,
481            redact: Some(RedactValue::Literal("[redacted]".to_string())),
482        }
483    }
484
485    fn value_secret_field(name: &str) -> PiiFieldModel {
486        PiiFieldModel {
487            ident: zyn::format_ident!("{}", name),
488            role: PiiFieldRole::Secret,
489            redact: Some(RedactValue::EmptyObject),
490        }
491    }
492
493    fn vec_secret_field(name: &str) -> PiiFieldModel {
494        PiiFieldModel {
495            ident: zyn::format_ident!("{}", name),
496            role: PiiFieldRole::Secret,
497            redact: Some(RedactValue::EmptyArray),
498        }
499    }
500
501    /// Multi-secret variant: `person_ref` (plaintext), `subject_id` (subject),
502    /// name/email/phone (secret) — mirrors `PersonCaptured`.
503    fn multi_secret_variant() -> PiiVariantModel {
504        PiiVariantModel {
505            event_type: "PersonCaptured".to_string(),
506            sentinel: "encrypted_pii".to_string(),
507            fields: vec![
508                field("person_ref", PiiFieldRole::Plaintext),
509                field("subject_id", PiiFieldRole::Subject),
510                str_secret_field("name"),
511                str_secret_field("email"),
512                str_secret_field("phone"),
513            ],
514        }
515    }
516
517    /// Single-secret variant: `person_ref` (plaintext), `subject_id` (subject),
518    /// `data` (secret, `Value` type) with a custom sentinel — mirrors `PersonDetailsUpdated`.
519    fn single_secret_variant() -> PiiVariantModel {
520        PiiVariantModel {
521            event_type: "PersonDetailsUpdated".to_string(),
522            sentinel: "encrypted_data".to_string(),
523            fields: vec![
524                field("person_ref", PiiFieldRole::Plaintext),
525                field("subject_id", PiiFieldRole::Subject),
526                value_secret_field("data"),
527            ],
528        }
529    }
530
531    /// Single-secret variant whose secret field is a `Vec<_>` type. Exercises
532    /// the `EmptyArray` redaction path emitted by `redact_arm`.
533    fn single_secret_vec_variant() -> PiiVariantModel {
534        PiiVariantModel {
535            event_type: "PhonesCaptured".to_string(),
536            sentinel: "encrypted_pii".to_string(),
537            fields: vec![
538                field("person_ref", PiiFieldRole::Plaintext),
539                field("subject_id", PiiFieldRole::Subject),
540                vec_secret_field("phone_numbers"),
541            ],
542        }
543    }
544
545    /// Multi-secret variant whose subject field is named `user_id` instead of
546    /// the conventional `subject_id`. Exercises the contract that
547    /// `#[pii(subject)]` accepts any field name and that name flows into
548    /// every JSON read/write site in the generated code.
549    fn multi_secret_variant_with_custom_subject() -> PiiVariantModel {
550        PiiVariantModel {
551            event_type: "PersonCaptured".to_string(),
552            sentinel: "encrypted_pii".to_string(),
553            fields: vec![
554                field("person_ref", PiiFieldRole::Plaintext),
555                field("user_id", PiiFieldRole::Subject),
556                str_secret_field("name"),
557                str_secret_field("email"),
558            ],
559        }
560    }
561
562    // ── classify_arm ──────────────────────────────────────────────────────────
563
564    #[test]
565    fn classify_arm_emits_event_type_match_pattern() {
566        let input = dummy_input();
567        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
568        zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
569    }
570
571    #[test]
572    fn classify_arm_extracts_subject_id_str() {
573        let input = dummy_input();
574        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
575        zyn::assert_tokens_contain!(output, "__subject_id_str");
576        zyn::assert_tokens_contain!(output, "\"subject_id\"");
577    }
578
579    #[test]
580    fn classify_arm_extracts_plaintext_fields_into_bindings() {
581        let input = dummy_input();
582        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
583        // plaintext field "person_ref" → binding "person_ref_str"
584        zyn::assert_tokens_contain!(output, "person_ref_str");
585        zyn::assert_tokens_contain!(output, "\"person_ref\"");
586    }
587
588    #[test]
589    fn classify_arm_clones_plaintext_fields_to_preserve_json_type() {
590        // Plaintext extraction must clone the JSON value to preserve its
591        // type. The previous implementation coerced through
592        // `as_str().unwrap_or("")`, silently corrupting non-string plaintext
593        // fields (numbers, nulls, bools) to the empty string and breaking
594        // deserialization on read-back.
595        //
596        // The single-secret variant is used because its generated body has
597        // no other call site for `unwrap_or` — the multi-secret bundling
598        // path legitimately uses `unwrap_or(Value::Null)`.
599        let input = dummy_input();
600        let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
601        let raw = output.to_string();
602        assert!(
603            !raw.contains("unwrap_or"),
604            "classify_arm must not coerce plaintext fields via `unwrap_or`, got: {raw}"
605        );
606    }
607
608    #[test]
609    fn classify_arm_multi_secret_bundles_into_object() {
610        let input = dummy_input();
611        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
612        // multi-secret path uses __inner for the object bundling
613        zyn::assert_tokens_contain!(output, "__inner");
614        zyn::assert_tokens_contain!(output, "\"name\"");
615        zyn::assert_tokens_contain!(output, "\"email\"");
616        zyn::assert_tokens_contain!(output, "\"phone\"");
617    }
618
619    #[test]
620    fn classify_arm_single_secret_uses_direct_value() {
621        let input = dummy_input();
622        let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
623        // single-secret path: no __inner bundling
624        let raw = output.to_string();
625        assert!(
626            !raw.contains("__inner"),
627            "single-secret classify arm must not bundle into a JSON object"
628        );
629        // direct field access for the single secret
630        zyn::assert_tokens_contain!(output, "\"data\"");
631    }
632
633    #[test]
634    fn classify_arm_uses_default_sentinel_in_closure() {
635        let input = dummy_input();
636        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
637        zyn::assert_tokens_contain!(output, "\"encrypted_pii\"");
638    }
639
640    #[test]
641    fn classify_arm_uses_custom_sentinel_in_closure() {
642        let input = dummy_input();
643        let output = zyn::zyn!(@classify_arm(variant = single_secret_variant()));
644        zyn::assert_tokens_contain!(output, "\"encrypted_data\"");
645    }
646
647    #[test]
648    fn classify_arm_closure_captures_plaintext_bindings() {
649        let input = dummy_input();
650        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
651        // The closure body must reference person_ref_str and __subject_id_str
652        zyn::assert_tokens_contain!(output, "build_encrypted_payload");
653        zyn::assert_tokens_contain!(output, "__subject_id_str");
654    }
655
656    #[test]
657    fn classify_arm_emits_pii_fields_struct() {
658        let input = dummy_input();
659        let output = zyn::zyn!(@classify_arm(variant = multi_secret_variant()));
660        zyn::assert_tokens_contain!(output, "PiiFields");
661        zyn::assert_tokens_contain!(output, "subject_id");
662        zyn::assert_tokens_contain!(output, "plaintext_pii");
663    }
664
665    #[test]
666    fn classify_arm_uses_custom_subject_name_in_both_read_and_write() {
667        // Regression guard for the bug where the encrypted-payload write path
668        // hardcoded `"subject_id"` instead of using the variant's actual
669        // subject field name. The read path already used `subject_str`; this
670        // test asserts both sides of `classify_arm` agree.
671        let input = dummy_input();
672        let output = zyn::zyn!(
673            @classify_arm(variant = multi_secret_variant_with_custom_subject())
674        );
675        let raw = output.to_string();
676
677        // The actual subject field name must appear in the generated tokens
678        // (it is used both to read the value and as the JSON key in the
679        // build_encrypted_payload closure).
680        assert!(
681            raw.contains("\"user_id\""),
682            "classify_arm must use the variant's actual subject field name as the JSON key, got: {raw}"
683        );
684
685        // The hardcoded literal `"subject_id"` must NOT appear when the
686        // variant's subject field is named something else.
687        assert!(
688            !raw.contains("\"subject_id\""),
689            "classify_arm must not emit hardcoded \"subject_id\" when the variant uses a different subject name, got: {raw}"
690        );
691    }
692
693    // ── extract_arm ───────────────────────────────────────────────────────────
694
695    #[test]
696    fn extract_arm_emits_event_type_match_pattern() {
697        let input = dummy_input();
698        let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
699        zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
700    }
701
702    #[test]
703    fn extract_arm_checks_sentinel_presence() {
704        let input = dummy_input();
705        let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
706        // sentinel presence check via .get("encrypted_pii")?
707        zyn::assert_tokens_contain!(output, "\"encrypted_pii\"");
708        zyn::assert_tokens_contain!(output, "get");
709    }
710
711    #[test]
712    fn extract_arm_uses_custom_sentinel() {
713        let input = dummy_input();
714        let output = zyn::zyn!(@extract_arm(variant = single_secret_variant()));
715        zyn::assert_tokens_contain!(output, "\"encrypted_data\"");
716    }
717
718    #[test]
719    fn extract_arm_extracts_subject_id() {
720        let input = dummy_input();
721        let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
722        zyn::assert_tokens_contain!(output, "__subject_id");
723        zyn::assert_tokens_contain!(output, "\"subject_id\"");
724    }
725
726    #[test]
727    fn extract_arm_base64_decodes_ciphertext_and_nonce() {
728        let input = dummy_input();
729        let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
730        zyn::assert_tokens_contain!(output, "STANDARD");
731        zyn::assert_tokens_contain!(output, "__ciphertext");
732        zyn::assert_tokens_contain!(output, "__nonce");
733        zyn::assert_tokens_contain!(output, "\"nonce\"");
734    }
735
736    #[test]
737    fn extract_arm_emits_encrypted_pii_extract_struct() {
738        let input = dummy_input();
739        let output = zyn::zyn!(@extract_arm(variant = multi_secret_variant()));
740        zyn::assert_tokens_contain!(output, "EncryptedPiiExtract");
741        zyn::assert_tokens_contain!(output, "ciphertext");
742        zyn::assert_tokens_contain!(output, "nonce");
743    }
744
745    // ── reconstruct_arm ───────────────────────────────────────────────────────
746
747    #[test]
748    fn reconstruct_arm_emits_event_type_match_pattern() {
749        let input = dummy_input();
750        let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
751        zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
752    }
753
754    #[test]
755    fn reconstruct_arm_contains_subject_field_name_and_binding() {
756        let input = dummy_input();
757        let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
758        zyn::assert_tokens_contain!(output, "\"subject_id\"");
759        zyn::assert_tokens_contain!(output, "__subject_id_val");
760    }
761
762    #[test]
763    fn reconstruct_arm_contains_plaintext_binding() {
764        let input = dummy_input();
765        let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
766        zyn::assert_tokens_contain!(output, "person_ref_str");
767        zyn::assert_tokens_contain!(output, "\"person_ref\"");
768    }
769
770    #[test]
771    fn reconstruct_arm_multi_secret_contains_indexed_plaintext_pii() {
772        let input = dummy_input();
773        let output = zyn::zyn!(@reconstruct_arm(variant = multi_secret_variant()));
774        // multi-secret: references plaintext_pii and each secret field name
775        zyn::assert_tokens_contain!(output, "plaintext_pii");
776        zyn::assert_tokens_contain!(output, "\"name\"");
777        zyn::assert_tokens_contain!(output, "\"email\"");
778        zyn::assert_tokens_contain!(output, "\"phone\"");
779    }
780
781    #[test]
782    fn reconstruct_arm_single_secret_uses_plaintext_pii_directly() {
783        let input = dummy_input();
784        let output = zyn::zyn!(@reconstruct_arm(variant = single_secret_variant()));
785        // single-secret: plaintext_pii used directly, not indexed
786        let raw = output.to_string();
787        assert!(
788            raw.contains("plaintext_pii"),
789            "should reference plaintext_pii, got: {raw}"
790        );
791        assert!(
792            !raw.contains("plaintext_pii [") && !raw.contains("plaintext_pii["),
793            "single-secret reconstruct must not index into plaintext_pii, got: {raw}"
794        );
795        zyn::assert_tokens_contain!(output, "\"data\"");
796    }
797
798    #[test]
799    fn reconstruct_arm_single_secret_emits_event_type() {
800        let input = dummy_input();
801        let output = zyn::zyn!(@reconstruct_arm(variant = single_secret_variant()));
802        zyn::assert_tokens_contain!(output, "\"PersonDetailsUpdated\"");
803    }
804
805    // ── redact_arm ────────────────────────────────────────────────────────────
806
807    #[test]
808    fn redact_arm_emits_event_type_match_pattern() {
809        let input = dummy_input();
810        let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
811        zyn::assert_tokens_contain!(output, "\"PersonCaptured\"");
812    }
813
814    #[test]
815    fn redact_arm_contains_subject_field_and_plaintext_binding() {
816        let input = dummy_input();
817        let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
818        zyn::assert_tokens_contain!(output, "__subject_id_val");
819        zyn::assert_tokens_contain!(output, "person_ref_str");
820    }
821
822    #[test]
823    fn redact_arm_multi_secret_emits_redacted_literal_for_string_fields() {
824        let input = dummy_input();
825        let output = zyn::zyn!(@redact_arm(variant = multi_secret_variant()));
826        // String fields → "[redacted]"
827        zyn::assert_tokens_contain!(output, "\"[redacted]\"");
828        zyn::assert_tokens_contain!(output, "\"name\"");
829        zyn::assert_tokens_contain!(output, "\"email\"");
830        zyn::assert_tokens_contain!(output, "\"phone\"");
831    }
832
833    #[test]
834    fn redact_arm_single_secret_emits_empty_object_for_value_field() {
835        let input = dummy_input();
836        let output = zyn::zyn!(@redact_arm(variant = single_secret_variant()));
837        // Value field → {}
838        let raw = output.to_string();
839        assert!(
840            raw.contains("{ }") || raw.contains("{}"),
841            "single-secret redact arm should contain empty object `{{}}`, got: {raw}"
842        );
843        zyn::assert_tokens_contain!(output, "\"data\"");
844    }
845
846    #[test]
847    fn redact_arm_single_secret_emits_event_type() {
848        let input = dummy_input();
849        let output = zyn::zyn!(@redact_arm(variant = single_secret_variant()));
850        zyn::assert_tokens_contain!(output, "\"PersonDetailsUpdated\"");
851    }
852
853    #[test]
854    fn redact_arm_single_secret_emits_empty_array_for_vec_field() {
855        // `Vec<_>` field → `[]` in the redacted JSON. The serde_json::json!
856        // macro accepts `[]` directly as an empty array value, just as it
857        // accepts `{}` for an empty object.
858        let input = dummy_input();
859        let output = zyn::zyn!(@redact_arm(variant = single_secret_vec_variant()));
860        let raw = output.to_string();
861        assert!(
862            raw.contains("[ ]") || raw.contains("[]"),
863            "single-secret Vec redact arm should contain empty array `[]`, got: {raw}"
864        );
865        zyn::assert_tokens_contain!(output, "\"phone_numbers\"");
866    }
867}