byteor_redaction/
ops.rs

1//! Core redaction application and value transformation helpers.
2
3use crate::{borrow::Cow, format, string::ToString, String, Vec};
4use sha2::{Digest, Sha256};
5
6use crate::types::{
7    Action, ActionOutcome, EnforcementMode, HashAlgo, Outcome, OutcomeCode, Record,
8    RedactionPolicy, Value,
9};
10
11/// Apply a deterministic action to a value.
12pub fn apply_action(action: &Action, value: &Value) -> ActionOutcome {
13    match action {
14        Action::Drop => ActionOutcome::Drop,
15        Action::ReplaceWith(v) => ActionOutcome::ReplaceWith(v.clone()),
16        Action::Hash(algo) => ActionOutcome::ReplaceWith(Value::String(hash_value(*algo, value))),
17        Action::Mask {
18            visible_prefix,
19            visible_suffix,
20            mask_char,
21        } => ActionOutcome::ReplaceWith(mask_value(
22            value,
23            *visible_prefix,
24            *visible_suffix,
25            *mask_char,
26        )),
27        Action::Truncate { max_bytes } => {
28            ActionOutcome::ReplaceWith(truncate_value(value, *max_bytes))
29        }
30    }
31}
32
33/// Apply a redaction action to a string payload field.
34pub fn apply_action_to_str(action: &Action, value: &str) -> Option<String> {
35    match apply_action(action, &Value::String(value.to_string())) {
36        ActionOutcome::Drop => None,
37        ActionOutcome::ReplaceWith(Value::String(s)) => Some(s),
38        ActionOutcome::ReplaceWith(Value::Bytes(bytes)) => Some(match String::from_utf8(bytes) {
39            Ok(s) => s,
40            Err(err) => String::from_utf8_lossy(&err.into_bytes()).into_owned(),
41        }),
42        ActionOutcome::ReplaceWith(Value::I64(v)) => Some(v.to_string()),
43        ActionOutcome::ReplaceWith(Value::U64(v)) => Some(v.to_string()),
44        ActionOutcome::ReplaceWith(Value::Bool(v)) => Some(
45            if v {
46                Cow::Borrowed("true")
47            } else {
48                Cow::Borrowed("false")
49            }
50            .into_owned(),
51        ),
52    }
53}
54
55/// Enforce a redaction policy against a record.
56///
57/// - `Block`: rejects if any rule field is present.
58/// - `Transform`: applies deterministic transforms (stable order) and accepts.
59pub fn enforce_in_place(
60    mode: EnforcementMode,
61    policy: &RedactionPolicy,
62    record: &mut Record,
63) -> Outcome {
64    match mode {
65        EnforcementMode::Block => {
66            for rule in policy.rules() {
67                if record.contains_key(&rule.field) {
68                    return Outcome::reject(
69                        OutcomeCode::RejectedBlockedField,
70                        format!("blocked sensitive field present name={}", rule.field),
71                    );
72                }
73            }
74            Outcome::accept(0, "accepted (no blocked fields present)".to_string())
75        }
76        EnforcementMode::Transform => {
77            let mut applied: u32 = 0;
78            for rule in policy.rules() {
79                let Some(existing) = record.get(&rule.field).cloned() else {
80                    continue;
81                };
82
83                match apply_action(&rule.action, &existing) {
84                    ActionOutcome::Drop => {
85                        let _ = record.remove(&rule.field);
86                        applied = applied.saturating_add(1);
87                    }
88                    ActionOutcome::ReplaceWith(v) => {
89                        let _ = record.insert(rule.field.clone(), v);
90                        applied = applied.saturating_add(1);
91                    }
92                }
93            }
94            Outcome::accept(applied, format!("accepted transforms_applied={applied}"))
95        }
96    }
97}
98
99fn hash_value(algo: HashAlgo, value: &Value) -> String {
100    match algo {
101        HashAlgo::Sha256 => {
102            let mut hasher = Sha256::new();
103            match value {
104                Value::String(s) => {
105                    hasher.update([b's', 0]);
106                    hasher.update(s.as_bytes());
107                }
108                Value::Bytes(b) => {
109                    hasher.update([b'b', 0]);
110                    hasher.update(b);
111                }
112                Value::I64(v) => {
113                    hasher.update([b'i', 0]);
114                    hasher.update(v.to_string().as_bytes());
115                }
116                Value::U64(v) => {
117                    hasher.update([b'u', 0]);
118                    hasher.update(v.to_string().as_bytes());
119                }
120                Value::Bool(v) => {
121                    hasher.update([b'o', 0]);
122                    hasher.update(if *v {
123                        b"true".as_slice()
124                    } else {
125                        b"false".as_slice()
126                    });
127                }
128            }
129            to_lower_hex(&hasher.finalize())
130        }
131    }
132}
133
134fn to_lower_hex(bytes: &[u8]) -> String {
135    const HEX: &[u8; 16] = b"0123456789abcdef";
136    let mut out = String::with_capacity(bytes.len() * 2);
137    for byte in bytes {
138        out.push(HEX[(byte >> 4) as usize] as char);
139        out.push(HEX[(byte & 0x0f) as usize] as char);
140    }
141    out
142}
143
144fn mask_value(
145    value: &Value,
146    visible_prefix: usize,
147    visible_suffix: usize,
148    mask_char: char,
149) -> Value {
150    match value {
151        Value::String(s) => {
152            Value::String(mask_string(s, visible_prefix, visible_suffix, mask_char))
153        }
154        Value::Bytes(bytes) => Value::String(mask_string(
155            &String::from_utf8_lossy(bytes),
156            visible_prefix,
157            visible_suffix,
158            mask_char,
159        )),
160        Value::I64(v) => Value::String(mask_string(
161            &v.to_string(),
162            visible_prefix,
163            visible_suffix,
164            mask_char,
165        )),
166        Value::U64(v) => Value::String(mask_string(
167            &v.to_string(),
168            visible_prefix,
169            visible_suffix,
170            mask_char,
171        )),
172        Value::Bool(v) => Value::String(mask_string(
173            if *v { "true" } else { "false" },
174            visible_prefix,
175            visible_suffix,
176            mask_char,
177        )),
178    }
179}
180
181fn mask_string(s: &str, visible_prefix: usize, visible_suffix: usize, mask_char: char) -> String {
182    let chars: Vec<char> = s.chars().collect();
183    if visible_prefix.saturating_add(visible_suffix) >= chars.len() {
184        return s.to_string();
185    }
186
187    let mut out = String::new();
188    for ch in chars.iter().take(visible_prefix) {
189        out.push(*ch);
190    }
191    for _ in 0..(chars.len() - visible_prefix - visible_suffix) {
192        out.push(mask_char);
193    }
194    for ch in chars.iter().skip(chars.len() - visible_suffix) {
195        out.push(*ch);
196    }
197    out
198}
199
200fn truncate_value(value: &Value, max_bytes: usize) -> Value {
201    match value {
202        Value::String(s) => Value::String(truncate_string(s, max_bytes)),
203        Value::Bytes(bytes) => Value::Bytes(bytes.iter().take(max_bytes).copied().collect()),
204        Value::I64(v) => Value::String(truncate_string(&v.to_string(), max_bytes)),
205        Value::U64(v) => Value::String(truncate_string(&v.to_string(), max_bytes)),
206        Value::Bool(v) => Value::String(truncate_string(
207            if *v { "true" } else { "false" },
208            max_bytes,
209        )),
210    }
211}
212
213fn truncate_string(s: &str, max_bytes: usize) -> String {
214    if s.len() <= max_bytes {
215        return s.to_string();
216    }
217    let mut end = max_bytes;
218    while end > 0 && !s.is_char_boundary(end) {
219        end -= 1;
220    }
221    s[..end].to_string()
222}