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}