Skip to main content

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}