Skip to main content

rustev_contract/
redaction.rs

1//! The redaction policy, `rustev.redaction/1`, its evaluation and the keyed
2//! digest (spec 028, 3.3 and 3.4).
3//!
4//! Every value a telemetry exporter writes passes [`Redactor::apply`], and
5//! every value a trace reader shows passes [`RedactionPolicy::view`]. Both
6//! are pure: no I/O, no clock, no async runtime. The field names and their
7//! class rules are the closed catalogue of [`crate::trace_fields`].
8//!
9//! The policy is a description of what may leave the process, never an
10//! authorization of any action (principle VI).
11
12use 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
26/// The schema string of a redaction policy.
27pub const REDACTION: &str = "rustev.redaction/1";
28
29/// The most `fields` overrides a policy may carry (3.4.1).
30pub const MAX_FIELD_OVERRIDES: usize = 512;
31/// The bounds of `truncate{bytes}` (3.4.1).
32pub const TRUNCATE_BYTES: std::ops::RangeInclusive<u32> = 1..=4096;
33/// The bounds of a digest key, in bytes (3.4.5).
34pub const KEY_BYTES: std::ops::RangeInclusive<usize> = 32..=64;
35/// The bounds of a digest key's `key_id`, in bytes (3.4.5).
36pub const KEY_ID_BYTES: std::ops::RangeInclusive<usize> = 1..=64;
37/// The domain tag of the keyed digest (3.4.5).
38pub const DIGEST_DOMAIN: &str = "rustev.digest/1";
39
40/// Field classes (3.3.1). Every exportable field belongs to exactly one.
41#[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    /// Text carried by generation results (spec 037).
63    GeneratedText,
64    /// Free text written at run time by an adapter, the runtime or the core.
65    FreeTextDetail,
66    /// Identifiers that correlate.
67    Id,
68    /// Unkeyed digests of content.
69    ContentDigest,
70    /// Closed vocabularies, booleans and plan-declared options.
71    Enum,
72    /// Counts, durations, costs and other numeric observations.
73    Number,
74    /// Identities and names the plan or definition authored.
75    PlanMetadata,
76    /// Requested and served model identities and routes.
77    ModelIdentity,
78}
79
80impl FieldClass {
81    /// Every class, in declaration order.
82    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    /// The class's wire name, which is also the digest domain (3.4.5).
100    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    /// A deny class: a policy may expose its fields only by naming each one
120    /// in `fields`, never by a class-wide `allow` or `truncate` (3.4.2).
121    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    /// The default action (3.4.3).
133    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
164/// The lineage rule for values carried in a judgment (3.3.2): a boolean or
165/// enum value is `enum`; any other value takes the most restrictive
166/// provenance class among its lineage's inputs, in the order
167/// `attributed-claim`, `third-party`, `user-supplied`, `model-derived`,
168/// `authenticated-app-field`, `system-of-record`; a value whose lineage has
169/// no input is `plan-metadata`. It never assigns a class less restrictive
170/// than an input the value may copy.
171pub 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/// What happens to a field's value on export (3.4.1).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
196#[serde(rename_all = "snake_case", deny_unknown_fields)]
197pub enum Action {
198    /// Withheld; its name is listed in `rustev.redaction.withheld`.
199    Drop,
200    /// Replaced by the keyed digest (3.4.5).
201    Digest,
202    /// Cut to at most `bytes` bytes at a UTF-8 character boundary.
203    Truncate { bytes: u32 },
204    /// Exported as recorded.
205    Allow,
206}
207
208impl Action {
209    /// How much an action withholds: `drop` most, then `digest`, then
210    /// `truncate` (a smaller bound withholding more), then `allow`. A reader
211    /// that cannot know which of several actions applies takes the most
212    /// withholding one.
213    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    /// The more withholding of two actions.
223    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/// One `fields` override (3.4.1).
244#[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/// A redaction policy, `rustev.redaction/1` (3.4.1). Parse it with
252/// [`RedactionPolicy::parse_validated`]; a policy that has not passed
253/// [`RedactionPolicy::validate`] is never applied.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct RedactionPolicy {
257    pub schema: String,
258    /// Class-wide actions; an unnamed class takes its default (3.4.3).
259    #[serde(default)]
260    pub classes: BTreeMap<FieldClass, Action>,
261    /// Per-field overrides, each naming one catalogue field exactly.
262    #[serde(default)]
263    pub fields: Vec<FieldOverride>,
264    /// Catalogue content fields whose GenAI content events may be emitted.
265    #[serde(default)]
266    pub content_events: Vec<String>,
267}
268
269crate::document::document!(RedactionPolicy, REDACTION, DESCRIPTOR_V1, RedactionPolicyId);
270
271/// Why a policy was refused (3.4.2). Each names the entry.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum PolicyError {
274    /// The bytes are not a `rustev.redaction/1` document within bounds.
275    Parse(DocumentError),
276    /// A `fields` entry names no catalogue field.
277    UnknownField { field: String },
278    /// A `fields` entry names a field another entry already names.
279    DuplicateField { field: String },
280    /// A `fields` entry names a fixed field (3.4.4).
281    FixedField { field: String },
282    /// A `classes` entry grants `allow` or `truncate` to a deny class.
283    ClassGrant { class: FieldClass, action: Action },
284    /// A `truncate` bound outside `1..=4096`; `entry` is the class or field.
285    TruncateBytes { entry: String, bytes: u32 },
286    /// More than [`MAX_FIELD_OVERRIDES`] `fields` entries.
287    TooManyFields { count: usize },
288    /// A `content_events` entry is not a catalogue content field.
289    NotContentField { field: String },
290    /// A `content_events` entry named twice.
291    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    /// The default policy (3.4.3): every class at its default action, no
337    /// override, no content event. Also the effective policy when the host
338    /// supplies none.
339    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/// How a reader may show one attribute of a trace line (3.11.3).
350#[derive(Debug, Clone, PartialEq)]
351pub enum View {
352    /// Shown, possibly cut further than the line holds it.
353    Shown(TraceAttr),
354    /// Withheld by the reader's policy.
355    Withheld,
356    /// The key is outside the catalogue; withheld with a notice.
357    NotInCatalogue,
358}
359
360impl RedactionPolicy {
361    /// Parse and validate (3.4.1, 3.4.2).
362    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    /// Every refusal of 3.4.2, naming the entry; the first one found.
369    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    /// The policy's identity, `sha256:` of its canonical bytes (3.4.1).
416    pub fn policy_id(&self) -> RedactionPolicyId {
417        // A parsed or constructed policy is all strings, integers and maps,
418        // which always have canonical bytes.
419        self.id().expect("a redaction policy has canonical bytes")
420    }
421
422    /// The class action: the `classes` entry, else the class default.
423    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    /// The effective action for `field` holding a value of `class` (3.4.4):
438    /// `allow` for a fixed field, else its `fields` override, else its
439    /// class's entry, else the class default.
440    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    /// The action a reader that does not know the value's class applies to
449    /// `field` (3.11.3): a fixed field is `allow`; an override applies as
450    /// written; a field with a fixed class takes that class's action; a
451    /// field under the lineage rule (3.3.2) takes the most withholding
452    /// action among every class the rule can assign, so the reader never
453    /// shows more than the policy allows for the value's actual class.
454    /// `None` for a key outside the catalogue.
455    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    /// Whether any action this policy can apply is `digest`, which needs a
470    /// key (3.4.5).
471    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    /// How a reader holding no key shows one attribute written as `attr`
479    /// under the key `field` (3.11.3): `drop` withholds it; `allow` shows it
480    /// as written; `digest` shows it only if the line marks it `digest`;
481    /// `truncate{bytes}` cuts a value marked `allow` or `truncated` and shows
482    /// a `digest` as is. A key outside the catalogue is never shown.
483    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
512/// Every class the lineage rule can assign (3.3.2).
513const 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
536/// Cut `s` to at most `max` bytes at a UTF-8 character boundary; true when
537/// bytes were removed.
538pub 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
549/// Cut every string of `v` to at most `max` bytes; other values are left as
550/// they are. True when any bytes were removed.
551pub 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/// Why a digest key was refused (3.4.5). Never carries key bytes.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum KeyError {
576    /// The key is not 32 to 64 bytes; `len` is its length.
577    KeyLength { len: usize },
578    /// The `key_id` is not 1 to 64 bytes.
579    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/// The host's digest key (3.4.5): 32 to 64 bytes, named by an opaque
596/// `key_id`. It is never serialized, and its `Debug` form shows only the
597/// `key_id`.
598#[derive(Clone)]
599pub struct DigestKey {
600    key_id: String,
601    key: Vec<u8>,
602}
603
604impl DigestKey {
605    /// A key of 32 to 64 bytes named by a `key_id` of 1 to 64 bytes.
606    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    /// The opaque name recorded on the root span.
620    pub fn key_id(&self) -> &str {
621        &self.key_id
622    }
623
624    /// `hmac-sha256:<64 lowercase hex>` of HMAC-SHA256 under this key over
625    /// `"rustev.digest/1" 0x00 <class> 0x00 <value bytes>`, where value
626    /// bytes are a string's UTF-8 or the canonical JSON of any other value.
627    /// The class is the domain, so one id correlates across the fields of
628    /// its class.
629    pub fn digest(&self, class: FieldClass, value: &TraceValue) -> String {
630        let bytes = match value {
631            TraceValue::Str(s) => s.as_bytes().to_vec(),
632            // Strings, integers, booleans and finite numbers always have
633            // record canonical bytes; a non-finite number has none and digests
634            // as `null`, the bytes JSON would write for it.
635            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
650/// The key bytes are overwritten when the key is dropped, so they do not
651/// outlive it in freed memory. Best effort in safe code: spec 028 3.1.1
652/// gives this crate `hmac` as its one new dependency (so no `zeroize`) and
653/// the crate forbids `unsafe` (so no volatile write); `black_box` keeps the
654/// compiler from eliding the overwrite as a dead store. The per-call HMAC
655/// state derived from the key is not covered.
656impl 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
671/// HMAC-SHA256 (RFC 2104) through the RustCrypto `hmac` crate.
672pub(crate) fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
673    // HMAC accepts a key of any length.
674    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/// Why a [`Redactor`] could not be built.
689#[derive(Debug, Clone, PartialEq, Eq)]
690pub enum RedactorError {
691    /// The policy failed validation.
692    Policy(PolicyError),
693    /// An effective action is `digest` and no key was given (3.4.5).
694    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/// One value after policy application.
711#[derive(Debug, Clone, PartialEq)]
712pub enum Applied {
713    /// Withheld; the field's name goes to `rustev.redaction.withheld`.
714    Dropped,
715    /// Exported, marked with how it was produced.
716    Kept(TraceAttr),
717}
718
719/// A validated policy with the key its `digest` actions need: the only path
720/// by which an exporter turns a record value into an exported one (3.4.7).
721#[derive(Debug, Clone)]
722pub struct Redactor {
723    policy: RedactionPolicy,
724    policy_id: RedactionPolicyId,
725    key: Option<DigestKey>,
726}
727
728impl Redactor {
729    /// Validate `policy` and refuse a missing key when any effective action
730    /// is `digest` (3.4.5).
731    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    /// The key's `key_id`, or `none` when no key is configured.
753    pub fn key_id(&self) -> &str {
754        self.key.as_ref().map(DigestKey::key_id).unwrap_or("none")
755    }
756
757    /// Apply the effective action for `field` holding `value` of `class`.
758    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                // Unreachable after `new`; withholding is the safe answer.
778                None => Applied::Dropped,
779            },
780        }
781    }
782}
783
784/// An array is digested element by element, so each id still correlates.
785fn 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        // RFC 4231, test cases 1 to 4, 6 and 7 (5 is a truncated output).
818        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}