er7_redact/action.rs
1//! The eight things redaction can do to a value.
2//!
3//! An action reads a leaf's **decoded** text and returns the text that
4//! should replace it, so `Mask` and `First` count characters the way a
5//! reader would rather than the way the wire spells them. What comes back
6//! is written through [`er7::Subcomponent::set`], which encodes any
7//! delimiter it contains (D11).
8//!
9//! Specified by spec §3.
10
11use crate::Error;
12use crate::pseudonym::pseudonym;
13use std::fmt;
14
15/// The placeholder every built-in policy writes.
16const REDACTED: &str = "REDACTED";
17
18/// The mask character `mask` uses when a policy file names none.
19const MASK: char = '*';
20
21/// What to do to a value the policy selected.
22///
23/// Every variant except [`Action::Null`] rewrites leaf text and leaves the
24/// shape of the message alone (D1); `Null` collapses the position it names
25/// to the explicit HL7 null, because that is what a null means (D6, spec
26/// §3.4).
27///
28/// Example:
29///
30/// ```
31/// use er7_redact::Action;
32///
33/// // Applied to a decoded value, with the pseudonym key.
34/// assert_eq!(Action::redacted().apply("EVERYWOMAN", 0).as_deref(), Some("REDACTED"));
35/// assert_eq!(Action::Mask('*').apply("EVERYWOMAN", 0).as_deref(), Some("**********"));
36/// assert_eq!(Action::First(4).apply("19610615", 0).as_deref(), Some("1961"));
37/// assert_eq!(Action::Last(4).apply("444333222", 0).as_deref(), Some("3222"));
38/// assert_eq!(Action::Clear.apply("EVERYWOMAN", 0).as_deref(), Some(""));
39///
40/// // `Keep` changes nothing, and so returns nothing to write.
41/// assert_eq!(Action::Keep.apply("EVERYWOMAN", 0), None);
42/// ```
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Action {
45 /// Leave the value exactly as it is.
46 ///
47 /// This is the action that **accepts** a position: its use is to
48 /// exempt one from a policy that rejects by default (spec §2.6).
49 ///
50 /// It does **not** undo an earlier rule: rules apply in order to the
51 /// message as it stands, so once a value has been replaced there is
52 /// nothing left to restore it from (D7, spec §2.4). That is also why a
53 /// rejecting rule beats a `Keep` for the same leaf whichever order the
54 /// two are in (D19).
55 Keep,
56 /// Empty the value, leaving the position in place.
57 ///
58 /// This is what redaction usually means. A receiver reads an empty
59 /// field as "the sender said nothing about this", which leaves its
60 /// stored value alone — see [`Action::Null`] for the other reading.
61 Clear,
62 /// Replace the named position with the explicit HL7 null `""`.
63 ///
64 /// A receiver reads this as "clear your stored value", which is a
65 /// different instruction from [`Action::Clear`] and a much stronger
66 /// one. It is also the only action that changes the shape of a
67 /// message: everything beneath the named position is replaced by one
68 /// subcomponent holding `""` (D6, spec §3.4).
69 Null,
70 /// Replace the value with fixed text, e.g. `REDACTED`.
71 ///
72 /// Delimiters in the text are encoded on the way in, so a replacement
73 /// can never break the message (D11).
74 Replace(String),
75 /// Replace each character of the value with this one, preserving the
76 /// length.
77 ///
78 /// The length is exactly what this leaks; use [`Action::Clear`] or
79 /// [`Action::Replace`] where that matters (spec §5.5).
80 Mask(char),
81 /// Keep the first `n` characters and drop the rest — a birth date
82 /// reduced to its year, say. `First(0)` is legal and equivalent to
83 /// [`Action::Clear`] (spec §3.7).
84 First(usize),
85 /// Keep the last `n` characters and drop the rest — an account number
86 /// reduced to the digits a human matches on.
87 Last(usize),
88 /// Replace the value with a stable pseudonym, so that equal values stay
89 /// equal across every message redacted with the same key.
90 ///
91 /// Read [`crate::pseudonym()`] before using this: a pseudonym preserves
92 /// linkage on purpose, and it is not a cryptographic guarantee (D12,
93 /// spec §7.3).
94 Pseudonym,
95}
96
97impl Action {
98 /// [`Action::Replace`] with the placeholder the built-in policies use.
99 ///
100 /// Example:
101 ///
102 /// ```
103 /// use er7_redact::Action;
104 ///
105 /// assert_eq!(Action::redacted(), Action::Replace("REDACTED".to_string()));
106 /// assert_eq!(Action::redacted().to_string(), "replace REDACTED");
107 /// ```
108 #[must_use]
109 pub fn redacted() -> Action {
110 Action::Replace(REDACTED.to_string())
111 }
112
113 /// Read an action as a policy file spells it (spec §6.2).
114 ///
115 /// The action name is matched case-insensitively; an argument, where
116 /// one is allowed, is the rest of the text as written.
117 ///
118 /// Example:
119 ///
120 /// ```
121 /// # fn main() -> Result<(), er7_redact::Error> {
122 /// use er7_redact::Action;
123 ///
124 /// assert_eq!(Action::parse("clear")?, Action::Clear);
125 /// assert_eq!(Action::parse("first 4")?, Action::First(4));
126 /// assert_eq!(Action::parse("replace NOT ON FILE")?,
127 /// Action::Replace("NOT ON FILE".to_string()));
128 ///
129 /// // The two arguments that may be left out have a default.
130 /// assert_eq!(Action::parse("replace")?, Action::redacted());
131 /// assert_eq!(Action::parse("mask")?, Action::Mask('*'));
132 ///
133 /// assert!(Action::parse("obfuscate").is_err());
134 /// assert!(Action::parse("first three").is_err());
135 /// # Ok(())
136 /// # }
137 /// ```
138 ///
139 /// # Errors
140 ///
141 /// [`Error::BadPolicy`] naming the problem: an unknown action name, an
142 /// argument where none belongs, a `mask` argument that is not one
143 /// character, or a `first`/`last` count that is not a number (spec
144 /// §6.4).
145 pub fn parse(text: &str) -> Result<Action, Error> {
146 let text = text.trim();
147 let (name, argument) = match text.split_once(char::is_whitespace) {
148 Some((name, argument)) => (name, argument.trim()),
149 None => (text, ""),
150 };
151 let bad = |detail: String| Err(Error::BadPolicy(detail));
152 // An argument where none belongs is a typo worth reporting, not
153 // something to ignore: `clear PID-5` is a rule missing a newline.
154 let none = |action: Action| {
155 if argument.is_empty() {
156 Ok(action)
157 } else {
158 Err(Error::BadPolicy(format!(
159 "action {name:?} takes no argument, but got {argument:?}"
160 )))
161 }
162 };
163 let count = |what: &str| match argument.parse::<usize>() {
164 Ok(n) => Ok(n),
165 Err(_) => Err(Error::BadPolicy(format!(
166 "action {what:?} wants a number of characters, not {argument:?}"
167 ))),
168 };
169 match name.to_ascii_lowercase().as_str() {
170 "keep" => none(Action::Keep),
171 "clear" => none(Action::Clear),
172 "null" => none(Action::Null),
173 "pseudonym" => none(Action::Pseudonym),
174 "replace" if argument.is_empty() => Ok(Action::redacted()),
175 "replace" => Ok(Action::Replace(argument.to_string())),
176 "mask" if argument.is_empty() => Ok(Action::Mask(MASK)),
177 "mask" => {
178 let mut characters = argument.chars();
179 match (characters.next(), characters.next()) {
180 (Some(mask), None) => Ok(Action::Mask(mask)),
181 _ => bad(format!(
182 "action \"mask\" wants one character, not {argument:?}"
183 )),
184 }
185 }
186 "first" => Ok(Action::First(count("first")?)),
187 "last" => Ok(Action::Last(count("last")?)),
188 "" => bad("expected an action".to_string()),
189 _ => bad(format!("unknown action {name:?}")),
190 }
191 }
192
193 /// The text this action writes in place of `value`, or `None` to leave
194 /// the value alone.
195 ///
196 /// `value` is the leaf's decoded text, and `key` is the redactor's
197 /// pseudonym key, used only by [`Action::Pseudonym`].
198 ///
199 /// [`Action::Keep`] returns `None` because it writes nothing, and so
200 /// does [`Action::Null`], which the redactor applies structurally
201 /// rather than as text (spec §3.4).
202 ///
203 /// Example:
204 ///
205 /// ```
206 /// use er7_redact::Action;
207 ///
208 /// // Counting is by character, so an escape that stands for one
209 /// // character counts as one.
210 /// assert_eq!(Action::First(3).apply("O'BRIEN", 0).as_deref(), Some("O'B"));
211 ///
212 /// // Asking for more characters than there are is not an error.
213 /// assert_eq!(Action::First(99).apply("MR", 0).as_deref(), Some("MR"));
214 /// assert_eq!(Action::Last(99).apply("MR", 0).as_deref(), Some("MR"));
215 /// ```
216 #[must_use]
217 pub fn apply(&self, value: &str, key: u64) -> Option<String> {
218 match self {
219 Action::Keep | Action::Null => None,
220 Action::Clear => Some(String::new()),
221 Action::Replace(text) => Some(text.clone()),
222 Action::Mask(mask) => Some(value.chars().map(|_| *mask).collect()),
223 Action::First(n) => Some(value.chars().take(*n).collect()),
224 Action::Last(n) => {
225 let skip = value.chars().count().saturating_sub(*n);
226 Some(value.chars().skip(skip).collect())
227 }
228 Action::Pseudonym => Some(pseudonym(key, value)),
229 }
230 }
231}
232
233impl fmt::Display for Action {
234 /// The spelling a policy file uses, so that a policy written out
235 /// re-reads as the same policy (D18, spec §6.5).
236 ///
237 /// The one value that does not survive the trip is `Replace` with empty
238 /// text, written as `clear`, because nothing downstream can tell the
239 /// two apart.
240 ///
241 /// Example:
242 ///
243 /// ```
244 /// use er7_redact::Action;
245 ///
246 /// assert_eq!(Action::Pseudonym.to_string(), "pseudonym");
247 /// assert_eq!(Action::First(4).to_string(), "first 4");
248 /// assert_eq!(Action::Mask('#').to_string(), "mask #");
249 /// assert_eq!(Action::Replace(String::new()).to_string(), "clear");
250 /// ```
251 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252 match self {
253 Action::Keep => write!(f, "keep"),
254 Action::Clear => write!(f, "clear"),
255 Action::Null => write!(f, "null"),
256 Action::Replace(text) if text.is_empty() => write!(f, "clear"),
257 Action::Replace(text) => write!(f, "replace {text}"),
258 Action::Mask(mask) => write!(f, "mask {mask}"),
259 Action::First(n) => write!(f, "first {n}"),
260 Action::Last(n) => write!(f, "last {n}"),
261 Action::Pseudonym => write!(f, "pseudonym"),
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn parses_every_action() {
272 // D18: every spelling in spec §6.2 reads, and writes back as
273 // itself, because a policy file is a compatibility surface.
274 let cases = [
275 ("keep", Action::Keep),
276 ("clear", Action::Clear),
277 ("null", Action::Null),
278 ("pseudonym", Action::Pseudonym),
279 ("replace REDACTED", Action::redacted()),
280 ("mask *", Action::Mask('*')),
281 ("first 4", Action::First(4)),
282 ("last 4", Action::Last(4)),
283 ];
284 for (text, action) in cases {
285 assert_eq!(Action::parse(text).unwrap(), action, "parsing {text:?}");
286 assert_eq!(action.to_string(), text, "writing {action:?}");
287 }
288
289 // Case is not significant in an action name.
290 assert_eq!(Action::parse("CLEAR").unwrap(), Action::Clear);
291 // The two defaults.
292 assert_eq!(Action::parse("replace").unwrap(), Action::redacted());
293 assert_eq!(Action::parse("mask").unwrap(), Action::Mask('*'));
294 // Replacement text may contain spaces, and keeps its case.
295 assert_eq!(
296 Action::parse("replace Not On File").unwrap(),
297 Action::Replace("Not On File".to_string())
298 );
299 }
300
301 #[test]
302 fn rejects_malformed_actions() {
303 for text in [
304 "",
305 "obfuscate",
306 "first",
307 "first three",
308 "mask ab",
309 "clear PID-5",
310 ] {
311 assert!(
312 Action::parse(text).is_err(),
313 "expected {text:?} to be rejected"
314 );
315 }
316 }
317
318 #[test]
319 fn every_action_but_pseudonym_is_idempotent() {
320 // D10: applying a policy twice is the same as applying it once,
321 // except for `Pseudonym`, which hashes whatever text it finds
322 // (spec §3.6).
323 let value = "EVERYWOMAN";
324 for action in [
325 Action::Clear,
326 Action::redacted(),
327 Action::Mask('*'),
328 Action::First(4),
329 Action::Last(4),
330 Action::First(0),
331 ] {
332 let once = action.apply(value, 0).expect("writes a value");
333 let twice = action.apply(&once, 0).expect("writes a value");
334 assert_eq!(once, twice, "{action} is not idempotent");
335 }
336
337 // And the documented exception.
338 let once = Action::Pseudonym.apply(value, 0).expect("writes a value");
339 let twice = Action::Pseudonym.apply(&once, 0).expect("writes a value");
340 assert_ne!(once, twice);
341 }
342
343 #[test]
344 fn counts_characters_not_bytes() {
345 // A decoded value can hold anything; `first` and `mask` must not
346 // split it mid-character.
347 assert_eq!(Action::First(2).apply("naïve", 0).as_deref(), Some("na"));
348 assert_eq!(Action::First(3).apply("naïve", 0).as_deref(), Some("naï"));
349 assert_eq!(Action::Last(3).apply("naïve", 0).as_deref(), Some("ïve"));
350 assert_eq!(
351 Action::Mask('*').apply("naïve", 0).as_deref(),
352 Some("*****")
353 );
354 }
355
356 #[test]
357 fn zero_counts_are_legal() {
358 // Spec §3.7: a policy computed from a table should not have to
359 // special-case the boundary.
360 assert_eq!(Action::First(0).apply("PATID1234", 0).as_deref(), Some(""));
361 assert_eq!(Action::Last(0).apply("PATID1234", 0).as_deref(), Some(""));
362 }
363}