1use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14
15use hmac::{Hmac, Mac};
16use serde::{Deserialize, Serialize};
17use sha2::Sha256;
18
19use crate::definition::ProvenanceClass;
20use crate::ids::RedactionPolicyId;
21use crate::limits::DESCRIPTOR_V1;
22use crate::trace::{Mark, TraceAttr, TraceValue};
23use crate::trace_fields::{self, ClassRule};
24use crate::{Document, DocumentError, Identified};
25
26pub const REDACTION: &str = "rustev.redaction/1";
28
29pub const MAX_FIELD_OVERRIDES: usize = 512;
31pub const TRUNCATE_BYTES: std::ops::RangeInclusive<u32> = 1..=4096;
33pub const KEY_BYTES: std::ops::RangeInclusive<usize> = 32..=64;
35pub const KEY_ID_BYTES: std::ops::RangeInclusive<usize> = 1..=64;
37pub const DIGEST_DOMAIN: &str = "rustev.digest/1";
39
40#[derive(
42 Debug,
43 Clone,
44 Copy,
45 PartialEq,
46 Eq,
47 PartialOrd,
48 Ord,
49 Hash,
50 Serialize,
51 Deserialize,
52 schemars::JsonSchema,
53)]
54#[serde(rename_all = "kebab-case")]
55pub enum FieldClass {
56 UserSupplied,
57 ThirdParty,
58 AttributedClaim,
59 AuthenticatedAppField,
60 SystemOfRecord,
61 ModelDerived,
62 GeneratedText,
64 FreeTextDetail,
66 Id,
68 ContentDigest,
70 Enum,
72 Number,
74 PlanMetadata,
76 ModelIdentity,
78}
79
80impl FieldClass {
81 pub const ALL: [FieldClass; 14] = [
83 FieldClass::UserSupplied,
84 FieldClass::ThirdParty,
85 FieldClass::AttributedClaim,
86 FieldClass::AuthenticatedAppField,
87 FieldClass::SystemOfRecord,
88 FieldClass::ModelDerived,
89 FieldClass::GeneratedText,
90 FieldClass::FreeTextDetail,
91 FieldClass::Id,
92 FieldClass::ContentDigest,
93 FieldClass::Enum,
94 FieldClass::Number,
95 FieldClass::PlanMetadata,
96 FieldClass::ModelIdentity,
97 ];
98
99 pub fn name(self) -> &'static str {
101 match self {
102 FieldClass::UserSupplied => "user-supplied",
103 FieldClass::ThirdParty => "third-party",
104 FieldClass::AttributedClaim => "attributed-claim",
105 FieldClass::AuthenticatedAppField => "authenticated-app-field",
106 FieldClass::SystemOfRecord => "system-of-record",
107 FieldClass::ModelDerived => "model-derived",
108 FieldClass::GeneratedText => "generated-text",
109 FieldClass::FreeTextDetail => "free-text-detail",
110 FieldClass::Id => "id",
111 FieldClass::ContentDigest => "content-digest",
112 FieldClass::Enum => "enum",
113 FieldClass::Number => "number",
114 FieldClass::PlanMetadata => "plan-metadata",
115 FieldClass::ModelIdentity => "model-identity",
116 }
117 }
118
119 pub fn is_deny(self) -> bool {
122 matches!(
123 self,
124 FieldClass::UserSupplied
125 | FieldClass::ThirdParty
126 | FieldClass::AttributedClaim
127 | FieldClass::GeneratedText
128 | FieldClass::FreeTextDetail
129 )
130 }
131
132 pub fn default_action(self) -> Action {
134 match self {
135 FieldClass::Id | FieldClass::ContentDigest => Action::Digest,
136 FieldClass::Enum
137 | FieldClass::Number
138 | FieldClass::PlanMetadata
139 | FieldClass::ModelIdentity => Action::Allow,
140 _ => Action::Drop,
141 }
142 }
143}
144
145impl fmt::Display for FieldClass {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(self.name())
148 }
149}
150
151impl From<ProvenanceClass> for FieldClass {
152 fn from(p: ProvenanceClass) -> Self {
153 match p {
154 ProvenanceClass::AuthenticatedAppField => FieldClass::AuthenticatedAppField,
155 ProvenanceClass::SystemOfRecord => FieldClass::SystemOfRecord,
156 ProvenanceClass::UserSupplied => FieldClass::UserSupplied,
157 ProvenanceClass::ThirdParty => FieldClass::ThirdParty,
158 ProvenanceClass::AttributedClaim => FieldClass::AttributedClaim,
159 ProvenanceClass::ModelDerived => FieldClass::ModelDerived,
160 }
161 }
162}
163
164pub fn lineage_class(
172 bool_or_enum: bool,
173 inputs: impl IntoIterator<Item = ProvenanceClass>,
174) -> FieldClass {
175 if bool_or_enum {
176 return FieldClass::Enum;
177 }
178 const ORDER: [ProvenanceClass; 6] = [
179 ProvenanceClass::AttributedClaim,
180 ProvenanceClass::ThirdParty,
181 ProvenanceClass::UserSupplied,
182 ProvenanceClass::ModelDerived,
183 ProvenanceClass::AuthenticatedAppField,
184 ProvenanceClass::SystemOfRecord,
185 ];
186 let present: BTreeSet<ProvenanceClass> = inputs.into_iter().collect();
187 ORDER
188 .iter()
189 .find(|c| present.contains(c))
190 .map(|c| FieldClass::from(*c))
191 .unwrap_or(FieldClass::PlanMetadata)
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
196#[serde(rename_all = "snake_case", deny_unknown_fields)]
197pub enum Action {
198 Drop,
200 Digest,
202 Truncate { bytes: u32 },
204 Allow,
206}
207
208impl Action {
209 fn withholding(self) -> (u8, u32) {
214 match self {
215 Action::Drop => (3, 0),
216 Action::Digest => (2, 0),
217 Action::Truncate { bytes } => (1, u32::MAX - bytes),
218 Action::Allow => (0, 0),
219 }
220 }
221
222 pub fn stricter(self, other: Action) -> Action {
224 if other.withholding() > self.withholding() {
225 other
226 } else {
227 self
228 }
229 }
230}
231
232impl fmt::Display for Action {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 match self {
235 Action::Drop => f.write_str("drop"),
236 Action::Digest => f.write_str("digest"),
237 Action::Truncate { bytes } => write!(f, "truncate{{{bytes}}}"),
238 Action::Allow => f.write_str("allow"),
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
245#[serde(deny_unknown_fields)]
246pub struct FieldOverride {
247 pub field: String,
248 pub action: Action,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct RedactionPolicy {
257 pub schema: String,
258 #[serde(default)]
260 pub classes: BTreeMap<FieldClass, Action>,
261 #[serde(default)]
263 pub fields: Vec<FieldOverride>,
264 #[serde(default)]
266 pub content_events: Vec<String>,
267}
268
269crate::document::document!(RedactionPolicy, REDACTION, DESCRIPTOR_V1, RedactionPolicyId);
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum PolicyError {
274 Parse(DocumentError),
276 UnknownField { field: String },
278 DuplicateField { field: String },
280 FixedField { field: String },
282 ClassGrant { class: FieldClass, action: Action },
284 TruncateBytes { entry: String, bytes: u32 },
286 TooManyFields { count: usize },
288 NotContentField { field: String },
290 DuplicateContentEvent { field: String },
292}
293
294impl fmt::Display for PolicyError {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 match self {
297 PolicyError::Parse(e) => write!(f, "redaction policy: {e}"),
298 PolicyError::UnknownField { field } => {
299 write!(f, "fields: {field:?} is not a catalogue field")
300 }
301 PolicyError::DuplicateField { field } => {
302 write!(f, "fields: {field:?} is named more than once")
303 }
304 PolicyError::FixedField { field } => {
305 write!(
306 f,
307 "fields: {field:?} is a fixed field and cannot be overridden"
308 )
309 }
310 PolicyError::ClassGrant { class, action } => write!(
311 f,
312 "classes: {class} is a deny class; {action} must be granted per field, never class-wide"
313 ),
314 PolicyError::TruncateBytes { entry, bytes } => {
315 write!(f, "{entry}: truncate bytes {bytes} outside 1..=4096")
316 }
317 PolicyError::TooManyFields { count } => {
318 write!(f, "fields: {count} entries, at most {MAX_FIELD_OVERRIDES}")
319 }
320 PolicyError::NotContentField { field } => {
321 write!(
322 f,
323 "content_events: {field:?} is not a catalogue content field"
324 )
325 }
326 PolicyError::DuplicateContentEvent { field } => {
327 write!(f, "content_events: {field:?} is named more than once")
328 }
329 }
330 }
331}
332
333impl std::error::Error for PolicyError {}
334
335impl Default for RedactionPolicy {
336 fn default() -> Self {
340 RedactionPolicy {
341 schema: REDACTION.to_string(),
342 classes: BTreeMap::new(),
343 fields: Vec::new(),
344 content_events: Vec::new(),
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq)]
351pub enum View {
352 Shown(TraceAttr),
354 Withheld,
356 NotInCatalogue,
358}
359
360impl RedactionPolicy {
361 pub fn parse_validated(bytes: &[u8]) -> Result<Self, PolicyError> {
363 let p = Self::parse(bytes).map_err(PolicyError::Parse)?;
364 p.validate()?;
365 Ok(p)
366 }
367
368 pub fn validate(&self) -> Result<(), PolicyError> {
370 for (class, action) in &self.classes {
371 if class.is_deny() && matches!(action, Action::Allow | Action::Truncate { .. }) {
372 return Err(PolicyError::ClassGrant {
373 class: *class,
374 action: *action,
375 });
376 }
377 check_truncate(&format!("classes.{class}"), *action)?;
378 }
379 if self.fields.len() > MAX_FIELD_OVERRIDES {
380 return Err(PolicyError::TooManyFields {
381 count: self.fields.len(),
382 });
383 }
384 let mut seen = BTreeSet::new();
385 for o in &self.fields {
386 let Some(entry) = trace_fields::lookup(&o.field) else {
387 return Err(PolicyError::UnknownField {
388 field: o.field.clone(),
389 });
390 };
391 if entry.rule == ClassRule::Fixed {
392 return Err(PolicyError::FixedField {
393 field: o.field.clone(),
394 });
395 }
396 if !seen.insert(o.field.as_str()) {
397 return Err(PolicyError::DuplicateField {
398 field: o.field.clone(),
399 });
400 }
401 check_truncate(&format!("fields.{}", o.field), o.action)?;
402 }
403 let mut seen = BTreeSet::new();
404 for c in &self.content_events {
405 if !trace_fields::lookup(c).is_some_and(|e| e.content) {
406 return Err(PolicyError::NotContentField { field: c.clone() });
407 }
408 if !seen.insert(c.as_str()) {
409 return Err(PolicyError::DuplicateContentEvent { field: c.clone() });
410 }
411 }
412 Ok(())
413 }
414
415 pub fn policy_id(&self) -> RedactionPolicyId {
417 self.id().expect("a redaction policy has canonical bytes")
420 }
421
422 pub fn class_action(&self, class: FieldClass) -> Action {
424 self.classes
425 .get(&class)
426 .copied()
427 .unwrap_or_else(|| class.default_action())
428 }
429
430 fn override_for(&self, field: &str) -> Option<Action> {
431 self.fields
432 .iter()
433 .find(|o| o.field == field)
434 .map(|o| o.action)
435 }
436
437 pub fn action(&self, field: &str, class: FieldClass) -> Action {
441 if trace_fields::lookup(field).is_some_and(|e| e.rule == ClassRule::Fixed) {
442 return Action::Allow;
443 }
444 self.override_for(field)
445 .unwrap_or_else(|| self.class_action(class))
446 }
447
448 pub fn reader_action(&self, field: &str) -> Option<Action> {
456 let entry = trace_fields::lookup(field)?;
457 Some(match entry.rule {
458 ClassRule::Fixed => Action::Allow,
459 ClassRule::Class(c) => self.override_for(field).unwrap_or(self.class_action(c)),
460 ClassRule::Lineage => self.override_for(field).unwrap_or_else(|| {
461 LINEAGE_CLASSES
462 .iter()
463 .map(|c| self.class_action(*c))
464 .fold(Action::Allow, Action::stricter)
465 }),
466 })
467 }
468
469 pub fn needs_key(&self) -> bool {
472 FieldClass::ALL
473 .iter()
474 .any(|c| self.class_action(*c) == Action::Digest)
475 || self.fields.iter().any(|o| o.action == Action::Digest)
476 }
477
478 pub fn view(&self, field: &str, attr: &TraceAttr) -> View {
484 let Some(action) = self.reader_action(field) else {
485 return View::NotInCatalogue;
486 };
487 match action {
488 Action::Drop => View::Withheld,
489 Action::Allow => View::Shown(attr.clone()),
490 Action::Digest => {
491 if attr.r == Mark::Digest {
492 View::Shown(attr.clone())
493 } else {
494 View::Withheld
495 }
496 }
497 Action::Truncate { bytes } => {
498 if attr.r == Mark::Digest {
499 View::Shown(attr.clone())
500 } else {
501 let (v, cut) = truncate_value(&attr.v, bytes as usize);
502 View::Shown(TraceAttr {
503 v,
504 r: if cut { Mark::Truncated } else { attr.r },
505 })
506 }
507 }
508 }
509 }
510}
511
512const LINEAGE_CLASSES: [FieldClass; 8] = [
514 FieldClass::Enum,
515 FieldClass::PlanMetadata,
516 FieldClass::AttributedClaim,
517 FieldClass::ThirdParty,
518 FieldClass::UserSupplied,
519 FieldClass::ModelDerived,
520 FieldClass::AuthenticatedAppField,
521 FieldClass::SystemOfRecord,
522];
523
524fn check_truncate(entry: &str, action: Action) -> Result<(), PolicyError> {
525 match action {
526 Action::Truncate { bytes } if !TRUNCATE_BYTES.contains(&bytes) => {
527 Err(PolicyError::TruncateBytes {
528 entry: entry.to_string(),
529 bytes,
530 })
531 }
532 _ => Ok(()),
533 }
534}
535
536pub fn truncate_str(s: &str, max: usize) -> (String, bool) {
539 if s.len() <= max {
540 return (s.to_string(), false);
541 }
542 let mut end = max;
543 while !s.is_char_boundary(end) {
544 end -= 1;
545 }
546 (s[..end].to_string(), true)
547}
548
549pub fn truncate_value(v: &TraceValue, max: usize) -> (TraceValue, bool) {
552 match v {
553 TraceValue::Str(s) => {
554 let (s, cut) = truncate_str(s, max);
555 (TraceValue::Str(s), cut)
556 }
557 TraceValue::StrArray(items) => {
558 let mut any = false;
559 let items = items
560 .iter()
561 .map(|s| {
562 let (s, cut) = truncate_str(s, max);
563 any |= cut;
564 s
565 })
566 .collect();
567 (TraceValue::StrArray(items), any)
568 }
569 other => (other.clone(), false),
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum KeyError {
576 KeyLength { len: usize },
578 KeyIdLength { len: usize },
580}
581
582impl fmt::Display for KeyError {
583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584 match self {
585 KeyError::KeyLength { len } => write!(f, "digest key is {len} bytes, not 32 to 64"),
586 KeyError::KeyIdLength { len } => {
587 write!(f, "digest key_id is {len} bytes, not 1 to 64")
588 }
589 }
590 }
591}
592
593impl std::error::Error for KeyError {}
594
595#[derive(Clone)]
599pub struct DigestKey {
600 key_id: String,
601 key: Vec<u8>,
602}
603
604impl DigestKey {
605 pub fn new(key_id: &str, key: &[u8]) -> Result<Self, KeyError> {
607 if !KEY_BYTES.contains(&key.len()) {
608 return Err(KeyError::KeyLength { len: key.len() });
609 }
610 if !KEY_ID_BYTES.contains(&key_id.len()) {
611 return Err(KeyError::KeyIdLength { len: key_id.len() });
612 }
613 Ok(DigestKey {
614 key_id: key_id.to_string(),
615 key: key.to_vec(),
616 })
617 }
618
619 pub fn key_id(&self) -> &str {
621 &self.key_id
622 }
623
624 pub fn digest(&self, class: FieldClass, value: &TraceValue) -> String {
630 let bytes = match value {
631 TraceValue::Str(s) => s.as_bytes().to_vec(),
632 other => {
636 crate::canonical::record_canonical_bytes(other).unwrap_or_else(|_| b"null".to_vec())
637 }
638 };
639 let mut msg =
640 Vec::with_capacity(DIGEST_DOMAIN.len() + class.name().len() + 2 + bytes.len());
641 msg.extend_from_slice(DIGEST_DOMAIN.as_bytes());
642 msg.push(0);
643 msg.extend_from_slice(class.name().as_bytes());
644 msg.push(0);
645 msg.extend_from_slice(&bytes);
646 format!("hmac-sha256:{}", hex(&hmac_sha256(&self.key, &msg)))
647 }
648}
649
650impl Drop for DigestKey {
657 fn drop(&mut self) {
658 self.key.fill(0);
659 std::hint::black_box(&mut self.key);
660 }
661}
662
663impl fmt::Debug for DigestKey {
664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
665 f.debug_struct("DigestKey")
666 .field("key_id", &self.key_id)
667 .finish_non_exhaustive()
668 }
669}
670
671pub(crate) fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
673 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key length");
675 mac.update(msg);
676 mac.finalize().into_bytes().into()
677}
678
679fn hex(bytes: &[u8]) -> String {
680 let mut s = String::with_capacity(bytes.len() * 2);
681 for b in bytes {
682 s.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
683 s.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
684 }
685 s
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
690pub enum RedactorError {
691 Policy(PolicyError),
693 DigestWithoutKey,
695}
696
697impl fmt::Display for RedactorError {
698 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
699 match self {
700 RedactorError::Policy(e) => write!(f, "{e}"),
701 RedactorError::DigestWithoutKey => {
702 f.write_str("the redaction policy digests a field and no digest key was given")
703 }
704 }
705 }
706}
707
708impl std::error::Error for RedactorError {}
709
710#[derive(Debug, Clone, PartialEq)]
712pub enum Applied {
713 Dropped,
715 Kept(TraceAttr),
717}
718
719#[derive(Debug, Clone)]
722pub struct Redactor {
723 policy: RedactionPolicy,
724 policy_id: RedactionPolicyId,
725 key: Option<DigestKey>,
726}
727
728impl Redactor {
729 pub fn new(policy: RedactionPolicy, key: Option<DigestKey>) -> Result<Self, RedactorError> {
732 policy.validate().map_err(RedactorError::Policy)?;
733 if key.is_none() && policy.needs_key() {
734 return Err(RedactorError::DigestWithoutKey);
735 }
736 let policy_id = policy.policy_id();
737 Ok(Redactor {
738 policy,
739 policy_id,
740 key,
741 })
742 }
743
744 pub fn policy(&self) -> &RedactionPolicy {
745 &self.policy
746 }
747
748 pub fn policy_id(&self) -> &RedactionPolicyId {
749 &self.policy_id
750 }
751
752 pub fn key_id(&self) -> &str {
754 self.key.as_ref().map(DigestKey::key_id).unwrap_or("none")
755 }
756
757 pub fn apply(&self, field: &str, class: FieldClass, value: &TraceValue) -> Applied {
759 match self.policy.action(field, class) {
760 Action::Drop => Applied::Dropped,
761 Action::Allow => Applied::Kept(TraceAttr {
762 v: value.clone(),
763 r: Mark::Allow,
764 }),
765 Action::Truncate { bytes } => {
766 let (v, cut) = truncate_value(value, bytes as usize);
767 Applied::Kept(TraceAttr {
768 v,
769 r: if cut { Mark::Truncated } else { Mark::Allow },
770 })
771 }
772 Action::Digest => match &self.key {
773 Some(k) => Applied::Kept(TraceAttr {
774 v: digest_value(k, class, value),
775 r: Mark::Digest,
776 }),
777 None => Applied::Dropped,
779 },
780 }
781 }
782}
783
784fn digest_value(k: &DigestKey, class: FieldClass, value: &TraceValue) -> TraceValue {
786 let one = |v: TraceValue| k.digest(class, &v);
787 match value {
788 TraceValue::StrArray(a) => {
789 TraceValue::StrArray(a.iter().map(|s| one(TraceValue::Str(s.clone()))).collect())
790 }
791 TraceValue::IntArray(a) => {
792 TraceValue::StrArray(a.iter().map(|i| one(TraceValue::Int(*i))).collect())
793 }
794 TraceValue::DoubleArray(a) => {
795 TraceValue::StrArray(a.iter().map(|d| one(TraceValue::Double(*d))).collect())
796 }
797 TraceValue::BoolArray(a) => {
798 TraceValue::StrArray(a.iter().map(|b| one(TraceValue::Bool(*b))).collect())
799 }
800 single => TraceValue::Str(k.digest(class, single)),
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 fn hexs(s: &str) -> Vec<u8> {
809 (0..s.len())
810 .step_by(2)
811 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
812 .collect()
813 }
814
815 #[test]
816 fn rfc_4231_hmac_sha256_vectors() {
817 let cases: [(Vec<u8>, Vec<u8>, &str); 6] = [
819 (
820 vec![0x0b; 20],
821 b"Hi There".to_vec(),
822 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
823 ),
824 (
825 b"Jefe".to_vec(),
826 b"what do ya want for nothing?".to_vec(),
827 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
828 ),
829 (
830 vec![0xaa; 20],
831 vec![0xdd; 50],
832 "773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe",
833 ),
834 (
835 hexs("0102030405060708090a0b0c0d0e0f10111213141516171819"),
836 vec![0xcd; 50],
837 "82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b",
838 ),
839 (
840 vec![0xaa; 131],
841 b"Test Using Larger Than Block-Size Key - Hash Key First".to_vec(),
842 "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
843 ),
844 (
845 vec![0xaa; 131],
846 b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm.".to_vec(),
847 "9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2",
848 ),
849 ];
850 for (key, msg, want) in cases {
851 assert_eq!(hex(&hmac_sha256(&key, &msg)), want);
852 }
853 }
854
855 #[test]
856 fn truncation_cuts_at_a_character_boundary() {
857 assert_eq!(truncate_str("héllo", 2), ("h".to_string(), true));
858 assert_eq!(truncate_str("héllo", 3), ("hé".to_string(), true));
859 assert_eq!(truncate_str("abc", 3), ("abc".to_string(), false));
860 }
861
862 #[test]
863 fn the_lineage_rule_takes_the_most_restrictive_input() {
864 use ProvenanceClass::*;
865 assert_eq!(lineage_class(true, [UserSupplied]), FieldClass::Enum);
866 assert_eq!(lineage_class(false, []), FieldClass::PlanMetadata);
867 assert_eq!(
868 lineage_class(false, [SystemOfRecord, UserSupplied, ModelDerived]),
869 FieldClass::UserSupplied
870 );
871 assert_eq!(
872 lineage_class(false, [ThirdParty, AttributedClaim]),
873 FieldClass::AttributedClaim
874 );
875 assert_eq!(
876 lineage_class(false, [AuthenticatedAppField, ModelDerived]),
877 FieldClass::ModelDerived
878 );
879 }
880
881 #[test]
882 fn stricter_orders_drop_digest_truncate_allow() {
883 let t8 = Action::Truncate { bytes: 8 };
884 let t4 = Action::Truncate { bytes: 4 };
885 assert_eq!(Action::Allow.stricter(t8), t8);
886 assert_eq!(t8.stricter(t4), t4);
887 assert_eq!(t4.stricter(Action::Digest), Action::Digest);
888 assert_eq!(Action::Drop.stricter(Action::Digest), Action::Drop);
889 }
890
891 #[test]
892 fn a_key_never_shows_in_debug() {
893 let k = DigestKey::new("test", &[0x42; 32]).unwrap();
894 let d = format!("{k:?}");
895 assert!(d.contains("test") && !d.contains("66") && !d.contains("42"));
896 }
897}