Skip to main content

autumn_web/classify/
mod.rs

1//! Compile-time data classification (issue #1654).
2//!
3//! Autumn's other protections for sensitive data are *name*-based and run at
4//! runtime: [`log::filter`](crate::log::filter) scrubs a key denylist,
5//! [`http_client`](crate::http_client) redacts three header names,
6//! [`gdpr`](crate::gdpr) keys erasure off table-name strings. Every one of them
7//! is one rename away from silently letting personal data through, because the
8//! classification lives in the developer's memory rather than in a type.
9//!
10//! This module moves one classification tier -- **personal data** -- into the
11//! type system, and gates one sink -- the [`Json`](crate::extract::Json)
12//! response -- on it.
13//!
14//! # The shape of the guarantee
15//!
16//! A `#[model]` column annotated `#[classified]` is generated as
17//! [`Classified<String, Marker>`](Classified) instead of `String`. The wrapper
18//! has **no** [`Serialize`](serde::Serialize) impl, no `Display`, no `Deref` and
19//! no `into_inner`, so there is no expression that puts the value somewhere a
20//! serializer can reach it. The model itself consequently loses its `Serialize`
21//! derive, so the whole record cannot be handed to a sink either. The only way
22//! out of the wrapper is [`Classified::declassify`], which consumes the value
23//! (move semantics make a release a single event) and takes a
24//! [`Declassification`] -- a boundary that was *declared*, names a purpose and a
25//! reason, and is registered into the build-time data-flow manifest.
26//!
27//! ```ignore
28//! #[autumn_web::model(table = "customers")]
29//! pub struct Customer {
30//!     pub id: i32,
31//!     pub name: String,
32//!     #[classified]
33//!     pub email: String,
34//! }
35//!
36//! autumn_web::declassify! {
37//!     /// Support agents need the customer's email address to answer the ticket.
38//!     pub SUPPORT_LOOKUP: CustomerEmailClassified => JsonResponse,
39//!     purpose = "support_lookup",
40//!     reason = "Support agents need the email address to answer the ticket.",
41//! }
42//!
43//! // Json(customer)                       -- compile error: the model is classified
44//! // Json(View { email: customer.email }) -- compile error: the field is classified
45//! let email = customer.email.declassify(&SUPPORT_LOOKUP); // released, recorded
46//! ```
47//!
48//! # What this is not
49//!
50//! The threat model is **drift detection, not an adversarial author** -- the
51//! same posture `docs/guide/security-posture-manifest.md` states for the
52//! security manifest. Two things follow from that, and both are deliberate:
53//! an author who reaches for [`Declassification::__declare`] directly instead
54//! of [`declassify!`](crate::declassify) is lying to their own manifest, and the
55//! boolean surface the wrapper must keep ([`PartialEq`], the `validator` rules)
56//! is an oracle someone could loop over. Neither is reachable by accident, and
57//! an author who wanted the value could simply declare a boundary. What this
58//! module closes is every path that hands a classified value -- or a
59//! serializable view of one -- to a sink *without anyone meaning to*.
60//!
61//! See `docs/guide/data-classification.md`.
62
63pub mod manifest;
64
65#[cfg(feature = "db")]
66mod diesel_types;
67#[cfg(feature = "db")]
68pub use diesel_types::ClassifiedText;
69
70mod validate;
71
72use std::marker::PhantomData;
73use std::sync::{Arc, Mutex, OnceLock};
74
75use serde::{Deserialize, Serialize};
76
77/// A data classification tier.
78///
79/// The first slice supports exactly one tier on purpose (issue #1654 "Out of
80/// Scope"): a second tier is a second variant here plus a second `#[classified]`
81/// spelling, and nothing else in the design changes.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84#[non_exhaustive]
85pub enum Classification {
86    /// Data identifying, or attributable to, a natural person.
87    PersonalData,
88}
89
90impl Classification {
91    /// The stable manifest spelling.
92    #[must_use]
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            Self::PersonalData => "personal_data",
96        }
97    }
98}
99
100impl std::fmt::Display for Classification {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(self.as_str())
103    }
104}
105
106/// A place classified data can leave the process.
107///
108/// The first slice gates the JSON response serializer only. Log/tracing events,
109/// outbound HTTP bodies and analytics emission are follow-up slices; each is a
110/// new variant here plus a new marker trait alongside [`JsonSink`].
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113#[non_exhaustive]
114pub enum Sink {
115    /// The [`Json`](crate::extract::Json) response body.
116    JsonResponse,
117}
118
119impl Sink {
120    /// The stable manifest spelling.
121    #[must_use]
122    pub const fn as_str(self) -> &'static str {
123        match self {
124            Self::JsonResponse => "json_response",
125        }
126    }
127
128    /// Every sink this slice can prove about, in manifest order.
129    #[must_use]
130    pub const fn all() -> &'static [Self] {
131        &[Self::JsonResponse]
132    }
133}
134
135impl std::fmt::Display for Sink {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.write_str(self.as_str())
138    }
139}
140
141/// The identity of one classified `#[model]` column, as a type.
142///
143/// `#[model]` generates one zero-sized marker per `#[classified]` column
144/// (`Customer::email` becomes `CustomerEmailClassified`) and implements this trait on
145/// it. The marker does three jobs at once: it names the field inside the
146/// compiler diagnostic when a leak is attempted, it keys a
147/// [`Declassification`] to exactly one column, and it turns a release into a
148/// *build-time* manifest edge rather than a runtime observation.
149pub trait ClassifiedField: 'static {
150    /// The model type's name, e.g. `"Customer"`. For display.
151    const MODEL: &'static str;
152    /// The model type's path, with the crate version, for example
153    /// `"my_app::models::customer@2::Customer"`.
154    ///
155    /// The manifest uses this, not [`MODEL`](Self::MODEL), as the column
156    /// identity. Two crates, modules, or crate versions can each define a
157    /// `Customer` with a classified `email`. A bare name would merge them into
158    /// one row. That hides an added classified column. It can also give the
159    /// reachable sinks of one model to the other.
160    const MODEL_PATH: &'static str;
161    /// The column's Rust field name, e.g. `"email"`.
162    const FIELD: &'static str;
163    /// The tier the column was annotated with.
164    const CLASSIFICATION: Classification;
165}
166
167mod sealed {
168    /// Sealed: outside this crate there is no way to name, let alone implement,
169    /// this trait, so [`super::ReleasedForSink`] cannot be implemented either.
170    pub trait NeverReleased {}
171}
172
173/// The proof obligation a classified value can never discharge.
174///
175/// [`Classified<T, F>`](Classified) implements [`Serialize`] only for an `F`
176/// that implements this trait, and nothing implements it. The impl exists at all
177/// so the failure is reported *with autumn's own diagnostic*, naming the field,
178/// rather than as serde's bare "trait bound not satisfied".
179///
180/// **Sealed.** The field markers `#[model]` generates live in the *application's*
181/// crate, so without the private supertrait one safe line
182/// (`impl ReleasedForSink for CustomerEmailClassified {}`) would satisfy the orphan
183/// rule and turn the `Serialize` impl back on for that column -- silently, with
184/// no boundary and no manifest row.
185#[diagnostic::on_unimplemented(
186    message = "`{Self}` is classified personal data and cannot be serialized into a sink",
187    label = "classified personal data would be released here",
188    note = "autumn gates every sink (today: the `Json` response body) on data that has passed a declassification boundary",
189    note = "declare a boundary with `autumn_web::declassify!` and release the value first: `value.declassify(&YOUR_BOUNDARY)`",
190    note = "see docs/guide/data-classification.md"
191)]
192pub trait ReleasedForSink: sealed::NeverReleased {}
193
194/// A value carrying a data classification.
195///
196/// `T` is the underlying value; `F` is the [`ClassifiedField`] marker naming the
197/// `#[model]` column it came from. Deliberately missing: `Serialize`, `Display`,
198/// `Deref`, `AsRef`, `Hash`, `into_inner`. The only exit is
199/// [`declassify`](Self::declassify).
200///
201/// # Residual surface
202///
203/// [`PartialEq`] is kept because the generated repository code needs it (record
204/// correlation, `UpdateDraft`'s changed-field check), and the `validator`
205/// delegations answer their rules over the real value. Both are *boolean*
206/// oracles: an author who writes a loop around `validate_regex` or `==` can
207/// narrow the value down without a boundary. That is deliberate, and it is the
208/// same posture `docs/guide/security-posture-manifest.md` states for the
209/// security manifest -- these gates detect drift, not an author attacking their
210/// own application, who could simply declare a boundary. What is closed is every
211/// path that hands the value, or a serializable view of it, to a sink by
212/// accident.
213pub struct Classified<T, F: ClassifiedField> {
214    value: T,
215    field: PhantomData<fn() -> F>,
216}
217
218impl<T, F: ClassifiedField> Classified<T, F> {
219    /// Classify a value.
220    ///
221    /// Entering the classification is free and unrecorded; only *leaving* it is
222    /// a boundary.
223    #[must_use]
224    pub const fn new(value: T) -> Self {
225        Self {
226            value,
227            field: PhantomData,
228        }
229    }
230
231    /// Release the value for the purpose the boundary declares.
232    ///
233    /// Consumes the classified value, so a release is a single event rather than
234    /// a permanent widening, and emits the auditable
235    /// [`DeclassificationRecord`].
236    #[must_use = "declassifying releases personal data; use the released value or do not release it"]
237    pub fn declassify(self, boundary: &Declassification<F>) -> T {
238        record_release(&boundary.record());
239        self.value
240    }
241
242    /// Release a *copy* of the value, leaving the classified original in place.
243    ///
244    /// Records exactly as [`declassify`](Self::declassify) does. Use it when the
245    /// record is borrowed (a `&Customer` from a repository read) and moving the
246    /// column out is not possible.
247    #[must_use = "declassifying releases personal data; use the released value or do not release it"]
248    pub fn declassify_cloned(&self, boundary: &Declassification<F>) -> T
249    where
250        T: Clone,
251    {
252        record_release(&boundary.record());
253        self.value.clone()
254    }
255
256    /// The tier this column was classified at.
257    #[must_use]
258    pub const fn classification() -> Classification {
259        F::CLASSIFICATION
260    }
261
262    /// The model this column belongs to.
263    #[must_use]
264    pub const fn model() -> &'static str {
265        F::MODEL
266    }
267
268    /// The path of the model this column belongs to. It has the crate version.
269    #[must_use]
270    pub const fn model_path() -> &'static str {
271        F::MODEL_PATH
272    }
273
274    /// The column's field name.
275    #[must_use]
276    pub const fn field() -> &'static str {
277        F::FIELD
278    }
279
280    /// Borrow the value for a check that cannot copy it out.
281    ///
282    /// Crate-private on purpose: a public borrow would be an unaudited
283    /// declassification for any `T: Copy` (and a `.clone()` away for the rest).
284    /// The `validator` delegations in [`validate`](super::validate) are the only
285    /// consumers.
286    pub(crate) const fn inner(&self) -> &T {
287        &self.value
288    }
289
290    /// Hand the value to the Diesel column wrapper on the way to the database.
291    ///
292    /// Crate-private, and its only caller is
293    /// [`ClassifiedText`](crate::classify::ClassifiedText), which is itself
294    /// opaque -- so this is not a way back to a `String`. Persisting a row is
295    /// not a gated sink; handing the value to a serializer is.
296    #[cfg(feature = "db")]
297    pub(crate) fn into_column_value(self) -> T {
298        self.value
299    }
300}
301
302impl<T, F: ClassifiedField> From<T> for Classified<T, F> {
303    fn from(value: T) -> Self {
304        Self::new(value)
305    }
306}
307
308impl<T: Clone, F: ClassifiedField> Clone for Classified<T, F> {
309    fn clone(&self) -> Self {
310        Self::new(self.value.clone())
311    }
312}
313
314impl<T: Default, F: ClassifiedField> Default for Classified<T, F> {
315    fn default() -> Self {
316        Self::new(T::default())
317    }
318}
319
320impl<T: PartialEq, F: ClassifiedField> PartialEq for Classified<T, F> {
321    fn eq(&self, other: &Self) -> bool {
322        self.value == other.value
323    }
324}
325
326impl<T: Eq, F: ClassifiedField> Eq for Classified<T, F> {}
327
328/// Redacted: the whole point is that the plaintext has no unaudited exit, and
329/// `Debug` output reaches panic messages, error pages and logs.
330impl<T, F: ClassifiedField> std::fmt::Debug for Classified<T, F> {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        f.write_str("<classified>")
333    }
334}
335
336/// Deserialization is unrestricted: taking classified data *in* is what an
337/// application does. Only releasing it is gated.
338impl<'de, T: Deserialize<'de>, F: ClassifiedField> Deserialize<'de> for Classified<T, F> {
339    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
340    where
341        D: serde::Deserializer<'de>,
342    {
343        T::deserialize(deserializer).map(Self::new)
344    }
345}
346
347/// Unreachable by construction: nothing implements [`ReleasedForSink`].
348///
349/// The impl exists so that an attempted leak is reported against autumn's
350/// diagnostic (which names the field and says what to do) instead of serde's.
351impl<T: Serialize, F: ClassifiedField + ReleasedForSink> Serialize for Classified<T, F> {
352    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
353    where
354        S: serde::Serializer,
355    {
356        self.value.serialize(serializer)
357    }
358}
359
360/// A declared declassification boundary: one column, one sink, one purpose.
361///
362/// Declare it with [`declassify!`](crate::declassify), which is also what
363/// registers the manifest edge. `F` ties the boundary to exactly one classified
364/// column, so one column's approved purpose cannot release another's data.
365pub struct Declassification<F: ClassifiedField> {
366    purpose: &'static str,
367    sink: Sink,
368    reason: &'static str,
369    field: PhantomData<fn() -> F>,
370}
371
372impl<F: ClassifiedField> Declassification<F> {
373    /// Construct a boundary. **Internal**: use
374    /// [`declassify!`](crate::declassify), which also registers the boundary in
375    /// the data-flow manifest. A boundary built here releases data that the
376    /// manifest will not list.
377    #[doc(hidden)]
378    #[must_use]
379    pub const fn __declare(purpose: &'static str, sink: Sink, reason: &'static str) -> Self {
380        Self {
381            purpose,
382            sink,
383            reason,
384            field: PhantomData,
385        }
386    }
387
388    /// The declared purpose, e.g. `"support_lookup"`.
389    #[must_use]
390    pub const fn purpose(&self) -> &'static str {
391        self.purpose
392    }
393
394    /// The sink the release is approved for.
395    #[must_use]
396    pub const fn sink(&self) -> Sink {
397        self.sink
398    }
399
400    /// Why the release is justified, in the declarer's own words.
401    #[must_use]
402    pub const fn reason(&self) -> &'static str {
403        self.reason
404    }
405
406    const fn record(&self) -> DeclassificationRecord {
407        DeclassificationRecord {
408            model: F::MODEL,
409            model_path: F::MODEL_PATH,
410            field: F::FIELD,
411            classification: F::CLASSIFICATION,
412            purpose: self.purpose,
413            sink: self.sink,
414            reason: self.reason,
415        }
416    }
417}
418
419impl<F: ClassifiedField> std::fmt::Debug for Declassification<F> {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        f.debug_struct("Declassification")
422            .field("model", &F::MODEL_PATH)
423            .field("field", &F::FIELD)
424            .field("purpose", &self.purpose)
425            .field("sink", &self.sink)
426            .finish_non_exhaustive()
427    }
428}
429
430/// The auditable record one release emits.
431///
432/// Carries no value -- recording the released plaintext would reintroduce the
433/// leak the type system just closed. It records *what* was released, *where to*
434/// and *why*.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct DeclassificationRecord {
437    /// The model the column belongs to.
438    pub model: &'static str,
439    /// That model's path, with the crate version. It is the model identity.
440    /// `model` is the display name.
441    pub model_path: &'static str,
442    /// The column's field name.
443    pub field: &'static str,
444    /// The tier the column was classified at.
445    pub classification: Classification,
446    /// The declared purpose of the release.
447    pub purpose: &'static str,
448    /// The sink the release was approved for.
449    pub sink: Sink,
450    /// The declarer's justification.
451    pub reason: &'static str,
452}
453
454type Observer = Arc<dyn Fn(&DeclassificationRecord) + Send + Sync>;
455
456fn observers() -> &'static Mutex<Vec<(u64, Observer)>> {
457    static OBSERVERS: OnceLock<Mutex<Vec<(u64, Observer)>>> = OnceLock::new();
458    OBSERVERS.get_or_init(|| Mutex::new(Vec::new()))
459}
460
461/// Removes the observer it was returned with when dropped.
462#[derive(Debug)]
463#[must_use = "the observer is removed as soon as this guard is dropped"]
464pub struct ReleaseObserverGuard(u64);
465
466impl Drop for ReleaseObserverGuard {
467    fn drop(&mut self) {
468        let mut list = observers()
469            .lock()
470            .unwrap_or_else(std::sync::PoisonError::into_inner);
471        list.retain(|(id, _)| *id != self.0);
472    }
473}
474
475/// Observe every declassification for as long as the guard lives.
476///
477/// Every release also emits a `tracing` event on the `autumn::declassification`
478/// target; this hook is for applications that persist the record (an audit
479/// table, a compliance export) and for tests.
480pub fn capture_releases<F>(observer: F) -> ReleaseObserverGuard
481where
482    F: Fn(&DeclassificationRecord) + Send + Sync + 'static,
483{
484    static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
485    let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
486    // Recover from poisoning rather than silently handing back a live-looking
487    // guard for an observer that was never registered: the list holds only
488    // `Arc`s, so a panic while another observer ran left nothing inconsistent.
489    let mut list = observers()
490        .lock()
491        .unwrap_or_else(std::sync::PoisonError::into_inner);
492    list.push((id, Arc::new(observer)));
493    drop(list);
494    ReleaseObserverGuard(id)
495}
496
497fn record_release(record: &DeclassificationRecord) {
498    tracing::info!(
499        target: "autumn::declassification",
500        model = record.model,
501        // The qualified path too, not just the display name: this event is the
502        // audit record, and two crates can each release a `Customer.email`.
503        model_path = record.model_path,
504        field = record.field,
505        classification = record.classification.as_str(),
506        purpose = record.purpose,
507        sink = record.sink.as_str(),
508        reason = record.reason,
509        "declassified",
510    );
511    // Clone the handles out before calling any of them: an observer that
512    // declassifies (or installs another observer) would otherwise deadlock on
513    // the same lock.
514    let snapshot: Vec<Observer> = observers()
515        .lock()
516        .unwrap_or_else(std::sync::PoisonError::into_inner)
517        .iter()
518        .map(|(_, o)| Arc::clone(o))
519        .collect();
520    for observer in snapshot {
521        observer(record);
522    }
523}
524
525/// The gate on the [`Json`](crate::extract::Json) response sink.
526///
527/// Blanket-implemented for everything that serializes, so no ordinary handler
528/// notices it exists. A `#[model]` with a `#[classified]` column has no
529/// `Serialize` impl and therefore no `JsonSink` impl, which is what turns the
530/// leak into a build failure.
531#[diagnostic::on_unimplemented(
532    message = "`{Self}` cannot be released into autumn's `Json` response sink",
533    label = "classified personal data would be released here",
534    note = "an autumn `#[model]` with `#[classified]` columns has no `Serialize` impl: the classification is carried by the type, so the whole record cannot reach a sink",
535    note = "release each classified column at a declared boundary and respond with the released view, e.g. `Json(SupportView::from_released(customer.email.declassify(&YOUR_BOUNDARY)))`",
536    note = "declare the boundary with `autumn_web::declassify!`; see docs/guide/data-classification.md"
537)]
538pub trait JsonSink: Serialize {}
539
540// `do_not_recommend` keeps rustc from descending into the blanket impl and
541// reporting serde's `T: Serialize` instead: the developer needs autumn's
542// message, which says what to do about it.
543#[diagnostic::do_not_recommend]
544impl<T: Serialize + ?Sized> JsonSink for T {}
545
546/// Declare a declassification boundary and register it in the data-flow
547/// manifest.
548///
549/// ```ignore
550/// autumn_web::declassify! {
551///     /// Support agents need the customer's email address to answer the ticket.
552///     pub SUPPORT_LOOKUP: CustomerEmailClassified => JsonResponse,
553///     purpose = "support_lookup",
554///     reason = "Support agents need the email address to answer the ticket.",
555/// }
556/// ```
557///
558/// The field marker (`CustomerEmailClassified`) is generated by `#[model]` for the
559/// `#[classified]` column. The sink name is a [`Sink`] variant. Both the
560/// `purpose` and the `reason` are string literals so the manifest can carry them
561/// without running the app.
562#[macro_export]
563macro_rules! declassify {
564    (
565        $(#[$meta:meta])*
566        $vis:vis $name:ident : $field:ty => $sink:ident,
567        purpose = $purpose:literal,
568        reason = $reason:literal $(,)?
569    ) => {
570        // A boundary whose justification is three spaces is the one nobody can
571        // review -- the same BLANK rule `acknowledge_stale` enforces.
572        const _: () = assert!(
573            !$crate::classify::reason_is_blank($purpose),
574            "declassify! requires a non-blank purpose",
575        );
576        const _: () = assert!(
577            !$crate::classify::reason_is_blank($reason),
578            "declassify! requires a non-blank reason",
579        );
580
581        $(#[$meta])*
582        $vis static $name: $crate::classify::Declassification<$field> =
583            $crate::classify::Declassification::__declare(
584                $purpose,
585                $crate::classify::Sink::$sink,
586                $reason,
587            );
588
589        $crate::reexports::inventory::submit! {
590            $crate::classify::manifest::DeclassificationDescriptor {
591                model: <$field as $crate::classify::ClassifiedField>::MODEL,
592                model_path: <$field as $crate::classify::ClassifiedField>::MODEL_PATH,
593                field: <$field as $crate::classify::ClassifiedField>::FIELD,
594                classification: <$field as $crate::classify::ClassifiedField>::CLASSIFICATION,
595                purpose: $purpose,
596                sink: $crate::classify::Sink::$sink,
597                reason: $reason,
598            }
599        }
600    };
601}
602
603/// Whether a declared justification is blank (empty or all whitespace).
604///
605/// `const` so [`declassify!`](crate::declassify) can reject it at compile time.
606/// Delegates to the cache-coherence gate's rule rather than carrying a second
607/// copy: two spellings of "blank" that could disagree is exactly the drift these
608/// gates exist to catch. That one is Unicode-aware, so a non-breaking space is
609/// still blank.
610#[doc(hidden)]
611#[must_use]
612pub const fn reason_is_blank(reason: &str) -> bool {
613    crate::cache::coherence::reason_is_blank(reason)
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    struct TestField;
621    impl ClassifiedField for TestField {
622        const MODEL: &'static str = "Customer";
623        const MODEL_PATH: &'static str = "test::Customer";
624        const FIELD: &'static str = "email";
625        const CLASSIFICATION: Classification = Classification::PersonalData;
626    }
627
628    /// A marker of its own for the observer tests. The observer registry is
629    /// process-wide and the test binary runs these in parallel, so a test that
630    /// counts releases has to be able to tell its own apart from a sibling's.
631    struct ObservedField;
632    impl ClassifiedField for ObservedField {
633        const MODEL: &'static str = "ObserverCustomer";
634        const MODEL_PATH: &'static str = "test::ObserverCustomer";
635        const FIELD: &'static str = "email";
636        const CLASSIFICATION: Classification = Classification::PersonalData;
637    }
638
639    static OBSERVED_BOUNDARY: Declassification<ObservedField> = Declassification::__declare(
640        "support_lookup",
641        Sink::JsonResponse,
642        "Support agents need the email address to answer the ticket.",
643    );
644
645    static BOUNDARY: Declassification<TestField> = Declassification::__declare(
646        "support_lookup",
647        Sink::JsonResponse,
648        "Support agents need the email address to answer the ticket.",
649    );
650
651    #[test]
652    fn debug_never_renders_the_classified_value() {
653        let c: Classified<String, TestField> = "ada@example.com".to_string().into();
654        assert_eq!(format!("{c:?}"), "<classified>");
655    }
656
657    #[test]
658    fn declassify_returns_the_value_and_records_the_release() {
659        let seen = Arc::new(Mutex::new(Vec::new()));
660        let sink = Arc::clone(&seen);
661        let _guard = capture_releases(move |r: &DeclassificationRecord| {
662            sink.lock().expect("observer").push(r.clone());
663        });
664
665        let c: Classified<String, ObservedField> = "ada@example.com".to_string().into();
666        assert_eq!(c.declassify(&OBSERVED_BOUNDARY), "ada@example.com");
667
668        let records = seen.lock().expect("observer").clone();
669        let mine: Vec<_> = records
670            .iter()
671            .filter(|r| r.model == ObservedField::MODEL)
672            .collect();
673        assert_eq!(mine.len(), 1, "{records:?}");
674        assert_eq!(mine[0].purpose, "support_lookup");
675        assert_eq!(mine[0].sink, Sink::JsonResponse);
676        assert_eq!(mine[0].classification, Classification::PersonalData);
677    }
678
679    #[test]
680    fn declassify_cloned_leaves_the_original_classified() {
681        let c: Classified<String, TestField> = "ada@example.com".to_string().into();
682        assert_eq!(c.declassify_cloned(&BOUNDARY), "ada@example.com");
683        // Still classified: the wrapper was borrowed, not consumed.
684        assert_eq!(format!("{c:?}"), "<classified>");
685    }
686
687    #[test]
688    fn the_observer_is_removed_when_its_guard_drops() {
689        struct GuardField;
690        impl ClassifiedField for GuardField {
691            const MODEL: &'static str = "GuardCustomer";
692            const MODEL_PATH: &'static str = "test::GuardCustomer";
693            const FIELD: &'static str = "email";
694            const CLASSIFICATION: Classification = Classification::PersonalData;
695        }
696        static GUARD_BOUNDARY: Declassification<GuardField> =
697            Declassification::__declare("support_lookup", Sink::JsonResponse, "Because.");
698
699        let seen = Arc::new(Mutex::new(0_usize));
700        {
701            let sink = Arc::clone(&seen);
702            // Count only this test's own releases: the registry is process-wide.
703            let _guard = capture_releases(move |r: &DeclassificationRecord| {
704                if r.model == GuardField::MODEL {
705                    *sink.lock().expect("observer") += 1;
706                }
707            });
708            let c: Classified<String, GuardField> = "a".to_string().into();
709            let _ = c.declassify(&GUARD_BOUNDARY);
710            assert_eq!(*seen.lock().expect("observer"), 1);
711        }
712        let after_guard = *seen.lock().expect("observer");
713        let c: Classified<String, GuardField> = "b".to_string().into();
714        let _ = c.declassify(&GUARD_BOUNDARY);
715        assert_eq!(*seen.lock().expect("observer"), after_guard);
716    }
717
718    #[test]
719    fn deserialize_accepts_classified_data_but_serialize_is_absent() {
720        let c: Classified<String, TestField> =
721            serde_json::from_str("\"ada@example.com\"").expect("deserialize");
722        assert_eq!(c.declassify(&BOUNDARY), "ada@example.com");
723        // There is no `Serialize` impl to call -- pinned by the trybuild
724        // fixtures in `tests/compile-fail/classified_*.rs`.
725    }
726
727    #[test]
728    fn classification_and_identity_come_from_the_marker() {
729        assert_eq!(
730            Classified::<String, TestField>::classification(),
731            Classification::PersonalData
732        );
733        assert_eq!(Classified::<String, TestField>::model(), "Customer");
734        assert_eq!(
735            Classified::<String, TestField>::model_path(),
736            "test::Customer"
737        );
738        assert_eq!(Classified::<String, TestField>::field(), "email");
739    }
740
741    #[test]
742    fn boundary_exposes_its_declaration() {
743        assert_eq!(BOUNDARY.purpose(), "support_lookup");
744        assert_eq!(BOUNDARY.sink(), Sink::JsonResponse);
745        assert!(BOUNDARY.reason().contains("Support agents"));
746    }
747
748    #[test]
749    fn equality_sees_through_the_wrapper_but_hashing_is_not_offered() {
750        // `PartialEq` is what the generated repository correlation and
751        // `UpdateDraft` changed-field check need. `Hash` is deliberately absent:
752        // a stable digest of a low-entropy personal value is `Serialize`, and so
753        // is an offline-brute-forceable view of the value itself.
754        let a: Classified<String, TestField> = "x".to_string().into();
755        let b: Classified<String, TestField> = "x".to_string().into();
756        let c: Classified<String, TestField> = "y".to_string().into();
757        assert_eq!(a, b);
758        assert_ne!(a, c);
759    }
760
761    #[test]
762    fn unicode_whitespace_is_still_a_blank_justification() {
763        assert!(reason_is_blank("\u{00a0}\u{2003}"));
764    }
765
766    #[test]
767    fn blank_justifications_are_rejected() {
768        assert!(reason_is_blank(""));
769        assert!(reason_is_blank("   \t\n"));
770        assert!(!reason_is_blank("support_lookup"));
771    }
772
773    #[test]
774    fn stable_spellings() {
775        assert_eq!(Classification::PersonalData.as_str(), "personal_data");
776        assert_eq!(Sink::JsonResponse.as_str(), "json_response");
777        assert_eq!(Sink::all(), &[Sink::JsonResponse]);
778    }
779}