er7_redact/policy.rs
1//! Rules, policies, the four built-in policies, and the policy file format.
2//!
3//! A [`Rule`] is one HL7 path and one [`Action`]. A [`Policy`] is an
4//! ordered list of rules plus the two things it does by default: its
5//! [`Posture`] — accept or reject every leaf no rule named — and what it
6//! does with a payload that is not ER7 at all ([`Unrecognised`]).
7//!
8//! Specified by spec §5 (the built-in policies) and §6 (the file format).
9
10use crate::{Action, Error};
11use er7::Path;
12use std::fmt;
13
14/// One HL7 path and what to do at it.
15///
16/// The path is an [`er7::Path`], so its notation and semantics are `er7`'s
17/// (that crate's spec §8.1): an omitted occurrence index means *every*
18/// segment of that name and *every* repetition of that field, which is
19/// what lets `OBX-5` cover a message with forty results.
20///
21/// A rule whose action is [`Action::Keep`] **accepts** the position it
22/// names; a rule with any other action **rejects** it. Where both name the
23/// same leaf, the rejecting one wins, whichever order they are in (D19,
24/// spec §2.4).
25///
26/// Example:
27///
28/// ```
29/// # fn main() -> Result<(), er7_redact::Error> {
30/// use er7_redact::{Action, Rule};
31///
32/// let rule = Rule::new("PID-5", Action::redacted())?;
33/// assert_eq!(rule.path.segment, "PID");
34/// assert_eq!(rule.to_string(), "PID-5 replace REDACTED");
35///
36/// // Or read one as a policy file spells it.
37/// assert_eq!(Rule::parse("PID-7 first 4")?.action, Action::First(4));
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Rule {
43 /// Where the rule applies.
44 pub path: Path,
45 /// What it does there.
46 pub action: Action,
47}
48
49impl Rule {
50 /// A rule from a path and an action.
51 ///
52 /// # Errors
53 ///
54 /// [`Error::Er7`] wrapping [`er7::Error::BadPath`] when `path` is not
55 /// an HL7 path — a zero index, a missing field number, trailing text.
56 pub fn new(path: &str, action: Action) -> Result<Rule, Error> {
57 Ok(Rule {
58 path: Path::parse(path)?,
59 action,
60 })
61 }
62
63 /// Read one policy line: a path, whitespace, an action (spec §6).
64 ///
65 /// Example:
66 ///
67 /// ```
68 /// # fn main() -> Result<(), er7_redact::Error> {
69 /// use er7_redact::{Action, Rule};
70 ///
71 /// let rule = Rule::parse(" OBX[2]-5 replace NOT ON FILE ")?;
72 /// assert_eq!(rule.path.to_string(), "OBX[2]-5");
73 /// assert_eq!(rule.action, Action::Replace("NOT ON FILE".to_string()));
74 ///
75 /// assert!(Rule::parse("PID-5").is_err()); // no action
76 /// assert!(Rule::parse("PID-0 clear").is_err()); // not a path
77 /// # Ok(())
78 /// # }
79 /// ```
80 ///
81 /// # Errors
82 ///
83 /// [`Error::BadPolicy`] naming the rule and the problem: a line with
84 /// no action, an action that does not exist, or a path that is not a
85 /// path.
86 pub fn parse(line: &str) -> Result<Rule, Error> {
87 let at = |e: Error| Error::BadPolicy(format!("rule {:?}: {e}", line.trim()));
88 let Some((path, action)) = split_line(line) else {
89 return Err(at(Error::BadPolicy(
90 "expected a path and an action".to_string(),
91 )));
92 };
93 let action = Action::parse(action).map_err(at)?;
94 Rule::new(path, action).map_err(at)
95 }
96}
97
98impl fmt::Display for Rule {
99 /// The path and the action, separated by one space — the policy file
100 /// spelling, so a rule written out reads back as itself.
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 write!(f, "{} {}", self.path, self.action)
103 }
104}
105
106/// The first word that accepts by default rather than naming a position.
107const ACCEPT: &str = "accept";
108
109/// The first word that rejects by default.
110const REJECT: &str = "reject";
111
112/// The first word that says what an unrecognised payload gets.
113const UNRECOGNISED: &str = "unrecognised";
114
115/// The spelling of [`UNRECOGNISED`] this crate also reads. Both are in the
116/// field, and a policy file that fails over one letter helps nobody.
117const UNRECOGNIZED: &str = "unrecognized";
118
119/// The path that set the fallback before 0.2, kept only to be refused with
120/// a sentence naming its replacement (spec §6.3).
121const REMOVED_FALLBACK: &str = "*";
122
123/// The column the default lines pad their first word to.
124const DEFAULT_WIDTH: usize = UNRECOGNISED.len();
125
126/// What a policy does with every leaf that no rule named (D9, spec §2.6).
127///
128/// A policy has exactly one of these and cannot leave it unstated: "redact
129/// what is listed" and "redact everything except what is listed" are
130/// different enough that guessing between them is not something a
131/// redaction crate may do.
132///
133/// Example:
134///
135/// ```
136/// # fn main() -> Result<(), er7_redact::Error> {
137/// use er7_redact::{Action, Policy, Posture, Redactor};
138///
139/// // Accept by default: only what a rule names is redacted.
140/// let listed = Policy::accept_all().with("PID-5", Action::redacted())?;
141///
142/// // Reject by default: only what a `keep` rule names survives.
143/// let all_but = Policy::reject_all().with("PID-5", Action::Keep)?;
144///
145/// assert_eq!(listed.posture, Posture::Accept);
146/// assert_eq!(all_but.posture, Posture::Reject(Action::redacted()));
147///
148/// let text = "MSH|^~\\&|LAB\rPID|1||9||SMITH";
149/// let mut one = er7::parse(text)?;
150/// let mut two = er7::parse(text)?;
151/// Redactor::new(listed).redact(&mut one);
152/// Redactor::new(all_but).redact(&mut two);
153///
154/// assert_eq!(one.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||REDACTED");
155/// assert_eq!(two.to_er7(), "MSH|^~\\&|REDACTED\rPID|REDACTED||REDACTED||SMITH");
156/// # Ok(())
157/// # }
158/// ```
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum Posture {
161 /// Accept by default: a leaf no rule named is left exactly as it is.
162 Accept,
163 /// Reject by default: a leaf no rule named gets this action.
164 ///
165 /// `Reject(Action::Keep)` is a contradiction — rejecting a value by
166 /// leaving it alone — and [`Policy::posture`] normalises it to
167 /// [`Posture::Accept`], which is what it means.
168 Reject(Action),
169}
170
171impl Posture {
172 /// How strict this posture is, for D20; see [`Policy::append`].
173 fn strictness(&self) -> u8 {
174 match self {
175 Posture::Accept => 0,
176 Posture::Reject(_) => 1,
177 }
178 }
179
180 /// Read `accept` or `reject [ACTION]` (spec §6.3).
181 fn parse(word: &str, argument: &str) -> Result<Posture, Error> {
182 if word == ACCEPT {
183 if argument.is_empty() {
184 Ok(Posture::Accept)
185 } else {
186 Err(Error::BadPolicy(format!(
187 "{ACCEPT:?} takes no argument, but got {argument:?}"
188 )))
189 }
190 } else if argument.is_empty() {
191 // A bare `reject` means the placeholder the built-in policies
192 // write, the same way a bare `replace` does (spec §6.2).
193 Ok(Posture::Reject(Action::redacted()))
194 } else {
195 Ok(normalise_posture(Posture::Reject(Action::parse(argument)?)))
196 }
197 }
198}
199
200impl fmt::Display for Posture {
201 /// The policy file spelling, so a policy written out re-reads as
202 /// itself (D18, spec §6.5).
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 match self {
205 Posture::Accept => write!(f, "{ACCEPT}"),
206 Posture::Reject(action) => write!(f, "{REJECT:<DEFAULT_WIDTH$} {action}"),
207 }
208 }
209}
210
211/// What a policy does with a payload that is not ER7 (D21, spec §2.8).
212///
213/// A payload with no header, or one that `er7` cannot parse, has no
214/// positions in it: no rule can name anything, and the posture has no leaf
215/// to reach. This is the only thing a policy can say about it.
216///
217/// Example:
218///
219/// ```
220/// use er7_redact::{Action, Policy, Redactor, Unrecognised};
221///
222/// let junk = "{\"patient\": \"EVERYWOMAN\"}";
223///
224/// // The curated policies refuse: nothing is written, and the caller says so.
225/// assert_eq!(Redactor::default().unrecognised(junk), None);
226///
227/// // `accept_all` passes it through, because it redacts nothing at all.
228/// let passed = Redactor::new(Policy::accept_all()).unrecognised(junk);
229/// assert_eq!(passed.as_deref(), Some(junk));
230///
231/// // `reject_all` masks it whole, because it rejects everything else.
232/// let masked = Redactor::new(Policy::reject_all()).unrecognised(junk);
233/// assert_eq!(masked.as_deref(), Some("*************************"));
234/// ```
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum Unrecognised {
237 /// Write the payload out unchanged.
238 Pass,
239 /// Apply this action to the whole payload, as if it were one value.
240 ///
241 /// `Apply(Action::Keep)` and `Apply(Action::Null)` write nothing in
242 /// the payload's place, which is [`Unrecognised::Pass`];
243 /// [`Policy::on_unrecognised`] normalises both to it.
244 Apply(Action),
245 /// Write nothing, and tell the caller the payload did not parse.
246 ///
247 /// The library does not raise this as an error — [`crate::Redactor`]
248 /// cannot fail (spec §9.2). It returns `None`, and the caller decides
249 /// what a refusal costs; the CLI makes it a diagnostic and exit 1
250 /// (spec §10.4).
251 Refuse,
252}
253
254impl Unrecognised {
255 /// Read `refuse`, `pass`, or an action (spec §6.3).
256 fn parse(argument: &str) -> Result<Unrecognised, Error> {
257 match argument.to_ascii_lowercase().as_str() {
258 "" => Err(Error::BadPolicy(format!(
259 "{UNRECOGNISED:?} wants \"refuse\", \"pass\", or an action"
260 ))),
261 "refuse" => Ok(Unrecognised::Refuse),
262 "pass" => Ok(Unrecognised::Pass),
263 _ => Ok(normalise_unrecognised(Unrecognised::Apply(Action::parse(
264 argument,
265 )?))),
266 }
267 }
268}
269
270impl fmt::Display for Unrecognised {
271 /// The policy file spelling, after the `unrecognised` keyword.
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 match self {
274 Unrecognised::Pass => write!(f, "pass"),
275 Unrecognised::Apply(action) => write!(f, "{action}"),
276 Unrecognised::Refuse => write!(f, "refuse"),
277 }
278 }
279}
280
281/// A posture that rejects by keeping is an accepting one; see
282/// [`Posture::Reject`].
283fn normalise_posture(posture: Posture) -> Posture {
284 match posture {
285 Posture::Reject(Action::Keep) => Posture::Accept,
286 posture => posture,
287 }
288}
289
290/// A disposition that writes nothing in the payload's place passes it
291/// through; see [`Unrecognised::Apply`].
292fn normalise_unrecognised(unrecognised: Unrecognised) -> Unrecognised {
293 match unrecognised {
294 Unrecognised::Apply(Action::Keep | Action::Null) => Unrecognised::Pass,
295 unrecognised => unrecognised,
296 }
297}
298
299/// An ordered list of rules, plus what to do with everything they do not
300/// name.
301///
302/// Rules apply **in order**, each to the message as it stands (D7, spec
303/// §2.4). The [`Posture`] then runs over every leaf that no rule named
304/// (D9, spec §2.6), and [`Unrecognised`] covers a payload that is not ER7
305/// at all (D21, spec §2.8).
306///
307/// # A reject beats an accept (D19)
308///
309/// A rule whose action is [`Action::Keep`] **accepts** the position it
310/// names; any other action **rejects** it. Where a leaf is named by both,
311/// the rejecting rule wins — **whichever order the two rules are in**, and
312/// at whatever depth, so a reject naming a whole segment beats an accept
313/// naming one field inside it.
314///
315/// A leaf named by both is a policy somebody got wrong, and redacting it
316/// is the direction that fails safely (spec §1.5, priority 1): a value
317/// redacted by mistake costs a policy edit, and a value left behind by
318/// mistake cannot be recalled.
319///
320/// The mirror of that rule: an accept naming a whole segment is **not**
321/// narrowed by the posture. `MSH keep` exempts every leaf of the header,
322/// including ones the policy's author never saw. Only a reject rule
323/// reaches back into it.
324///
325/// Example:
326///
327/// ```
328/// # fn main() -> Result<(), er7_redact::Error> {
329/// use er7_redact::{Action, Policy, Redactor};
330///
331/// // Redact what is listed...
332/// let listed = Policy::accept_all()
333/// .with("PID-5", Action::redacted())?
334/// .with("PID-7", Action::First(4))?;
335///
336/// // ...or redact everything that is not.
337/// let everything_else = Policy::reject_all().with("MSH", Action::Keep)?;
338///
339/// let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN||19610615")?;
340/// Redactor::new(listed).redact(&mut message);
341/// assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||REDACTED^REDACTED||1961");
342/// # Ok(())
343/// # }
344/// ```
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct Policy {
347 /// The rules, in the order they apply.
348 pub rules: Vec<Rule>,
349 /// What every leaf no rule named gets.
350 pub posture: Posture,
351 /// What a payload that is not ER7 gets.
352 pub unrecognised: Unrecognised,
353}
354
355// `Default` is deliberately not implemented, and neither is a `new`. Both
356// would have to choose a posture without being asked: an accepting empty
357// policy silently redacts nothing, and a curated one silently redacts
358// forty positions. A caller names the policy they mean (spec §5).
359impl Policy {
360 /// Accept everything: no rules, nothing redacted, and a payload that
361 /// is not ER7 passed through unchanged (spec §5.6).
362 ///
363 /// This is the starting point for building a policy rule by rule. On
364 /// its own it does nothing at all, and it says so: a policy named
365 /// "accept all" that quietly replaced an unparseable payload with
366 /// `***` would be the one surprise it has no excuse for.
367 ///
368 /// A policy *file* that states no defaults is not quite this: it
369 /// accepts by default too, but it refuses an unrecognised payload,
370 /// because it was written by somebody who did not think about one
371 /// (spec §6.1). Ask for [`Unrecognised::Pass`] in the file to get it.
372 ///
373 /// Example:
374 ///
375 /// ```
376 /// # fn main() -> Result<(), er7_redact::Error> {
377 /// use er7_redact::{Action, Policy, Posture, Redactor, Unrecognised};
378 ///
379 /// let policy = Policy::accept_all();
380 /// assert_eq!(policy.posture, Posture::Accept);
381 /// assert_eq!(policy.unrecognised, Unrecognised::Pass);
382 /// assert!(policy.is_empty());
383 ///
384 /// // It changes nothing, and reports nothing.
385 /// let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH")?;
386 /// let report = Redactor::new(policy).redact(&mut message);
387 /// assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||SMITH");
388 /// assert!(report.is_empty());
389 /// # Ok(())
390 /// # }
391 /// ```
392 #[must_use]
393 pub fn accept_all() -> Policy {
394 Policy {
395 rules: Vec::new(),
396 posture: Posture::Accept,
397 unrecognised: Unrecognised::Pass,
398 }
399 }
400
401 /// Reject everything: no rules, `replace REDACTED` over every leaf,
402 /// and a payload that is not ER7 masked whole (spec §5.6).
403 ///
404 /// The strictest thing in the crate, and it takes the header with it:
405 /// everything from `MSH-3` on reads `REDACTED`, so the message is no
406 /// longer routable or identifiable. [`Policy::all_but_the_header`] is
407 /// the same posture with the header kept, and is usually what is
408 /// wanted.
409 ///
410 /// `MSH-1` and `MSH-2` survive, as they survive everything: they are
411 /// the delimiters themselves (D5, spec §4.4).
412 ///
413 /// Example:
414 ///
415 /// ```
416 /// # fn main() -> Result<(), er7_redact::Error> {
417 /// use er7_redact::{Action, Policy, Redactor};
418 ///
419 /// let policy = Policy::reject_all().with("OBX-2", Action::Keep)?;
420 /// let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
421 /// Redactor::new(policy).redact(&mut message);
422 ///
423 /// assert_eq!(
424 /// message.to_er7(),
425 /// "MSH|^~\\&|REDACTED\rOBX|REDACTED|NM|REDACTED||REDACTED",
426 /// );
427 /// # Ok(())
428 /// # }
429 /// ```
430 #[must_use]
431 pub fn reject_all() -> Policy {
432 Policy {
433 rules: Vec::new(),
434 posture: Posture::Reject(Action::redacted()),
435 unrecognised: Unrecognised::Apply(Action::Mask('*')),
436 }
437 }
438
439 /// The curated policy: the positions that carry a patient identifier
440 /// in `PID`, `NK1`, `PV1`, `GT1`, and `IN1`.
441 ///
442 /// It **accepts by default**, so a position the table does not name is
443 /// left as it is, and it **refuses** a payload that is not ER7: a list
444 /// of positions has no opinion about input with no positions in it,
445 /// and refusing is the fail-closed answer (spec §2.8).
446 ///
447 /// The whole table is written out in spec §5.1, with a reason for each
448 /// action. **It is a starting point, not a compliance certification**
449 /// (D14): it does not touch free text, quasi-identifiers, or local `Z`
450 /// segments, and it does not know which positions your senders
451 /// actually use. Read spec §5.4 and §5.5 before relying on it.
452 ///
453 /// Example:
454 ///
455 /// ```
456 /// # fn main() -> Result<(), er7_redact::Error> {
457 /// use er7_redact::{Policy, Redactor};
458 ///
459 /// let mut message = er7::parse(
460 /// "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F|||12 ELM ST^^BOSTON",
461 /// )?;
462 /// let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
463 ///
464 /// assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
465 /// assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
466 /// assert_eq!(message.query("PID-11.1")?.as_deref(), Some(""));
467 /// assert_ne!(message.query("PID-3")?.as_deref(), Some("PATID1234"));
468 ///
469 /// // The sex is not an identifier, so the default policy leaves it.
470 /// assert_eq!(message.query("PID-8")?.as_deref(), Some("F"));
471 /// assert!(!report.is_empty());
472 /// # Ok(())
473 /// # }
474 /// ```
475 ///
476 /// # Panics
477 ///
478 /// Only if the table below is edited to hold something that is not an
479 /// HL7 path — every entry is a literal, and
480 /// `the_documented_positions_match_the_built_in_policy` in
481 /// `tests/integration.rs` checks the whole table against spec §5.1.
482 #[must_use]
483 pub fn patient_identifiers() -> Policy {
484 // Spec §5.1 is the normative table; this list is its executable
485 // form, in the same order, and the two are changed together.
486 let table: &[(&str, Action)] = &[
487 // PID — patient identification. An identifier field names its
488 // first component, which is the ID number itself: the
489 // assigning authority and identifier type beside it identify
490 // the interface rather than the patient, and a message that
491 // keeps them still looks like the one it came from (spec §5.1).
492 ("PID-2.1", Action::Pseudonym),
493 ("PID-3.1", Action::Pseudonym),
494 ("PID-4.1", Action::Pseudonym),
495 ("PID-5", Action::redacted()),
496 ("PID-6", Action::redacted()),
497 ("PID-7", Action::First(4)),
498 ("PID-9", Action::redacted()),
499 ("PID-11", Action::Clear),
500 ("PID-12", Action::Clear),
501 ("PID-13", Action::Clear),
502 ("PID-14", Action::Clear),
503 ("PID-18.1", Action::Pseudonym),
504 ("PID-19", Action::Clear),
505 ("PID-20", Action::Clear),
506 ("PID-21.1", Action::Pseudonym),
507 ("PID-23", Action::Clear),
508 ("PID-29", Action::First(4)),
509 // NK1 — next of kin.
510 ("NK1-2", Action::redacted()),
511 ("NK1-4", Action::Clear),
512 ("NK1-5", Action::Clear),
513 ("NK1-6", Action::Clear),
514 // PV1 — patient visit.
515 ("PV1-5.1", Action::Pseudonym),
516 ("PV1-7", Action::redacted()),
517 ("PV1-8", Action::redacted()),
518 ("PV1-9", Action::redacted()),
519 ("PV1-17", Action::redacted()),
520 ("PV1-19.1", Action::Pseudonym),
521 // GT1 — guarantor.
522 ("GT1-2.1", Action::Pseudonym),
523 ("GT1-3", Action::redacted()),
524 ("GT1-4", Action::redacted()),
525 ("GT1-5", Action::Clear),
526 ("GT1-6", Action::Clear),
527 ("GT1-7", Action::Clear),
528 ("GT1-8", Action::First(4)),
529 ("GT1-12", Action::Clear),
530 // IN1 — insurance.
531 ("IN1-16", Action::redacted()),
532 ("IN1-18", Action::First(4)),
533 ("IN1-19", Action::Clear),
534 ("IN1-36", Action::Pseudonym),
535 ("IN1-49.1", Action::Pseudonym),
536 ];
537 let rules = table
538 .iter()
539 .map(|(path, action)| {
540 Rule::new(path, action.clone()).expect("built-in paths are well-formed")
541 })
542 .collect();
543 Policy {
544 rules,
545 posture: Posture::Accept,
546 unrecognised: Unrecognised::Refuse,
547 }
548 }
549
550 /// The other posture, curated: reject every value, and keep the `MSH`
551 /// header so the message stays routable (spec §5.2).
552 ///
553 /// Use it when the message is unfamiliar, or when the answer to "is
554 /// there anything else in here?" has to be "no" rather than "not that
555 /// I listed". The cost is that nothing below `MSH` is clinically
556 /// meaningful afterwards; add `Keep` rules for what a test needs.
557 ///
558 /// Like [`Policy::patient_identifiers`] it **refuses** a payload that
559 /// is not ER7 rather than guessing (spec §2.8).
560 ///
561 /// The header exception is an ordinary accept rule, so an ordinary
562 /// reject rule overrides it (D19) — `.with("MSH", Action::redacted())`
563 /// takes the header too.
564 ///
565 /// Example:
566 ///
567 /// ```
568 /// # fn main() -> Result<(), er7_redact::Error> {
569 /// use er7_redact::{Action, Policy, Redactor};
570 ///
571 /// let policy = Policy::all_but_the_header().with("OBX-2", Action::Keep)?;
572 /// let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
573 /// Redactor::new(policy).redact(&mut message);
574 ///
575 /// assert_eq!(
576 /// message.to_er7(),
577 /// "MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED",
578 /// );
579 /// # Ok(())
580 /// # }
581 /// ```
582 ///
583 /// # Panics
584 ///
585 /// Only if the `MSH` literal below stops being an HL7 path, which no
586 /// caller can cause.
587 #[must_use]
588 pub fn all_but_the_header() -> Policy {
589 Policy::reject_all()
590 .with("MSH", Action::Keep)
591 .expect("built-in paths are well-formed")
592 .on_unrecognised(Unrecognised::Refuse)
593 }
594
595 /// Add a rule, for building a policy in one expression.
596 ///
597 /// # Errors
598 ///
599 /// [`Error::Er7`] when `path` is not an HL7 path; see [`Rule::new`].
600 pub fn with(mut self, path: &str, action: Action) -> Result<Policy, Error> {
601 self.rules.push(Rule::new(path, action)?);
602 Ok(self)
603 }
604
605 /// Set what every leaf no rule named gets (spec §2.6).
606 ///
607 /// [`Posture::Reject`] with [`Action::Keep`] is normalised to
608 /// [`Posture::Accept`], so that the policy file's `reject keep` and
609 /// this method agree about what they mean.
610 ///
611 /// This is the only way to make a policy *less* strict: appending one
612 /// policy to another never weakens it (D20, [`Policy::append`]).
613 #[must_use]
614 pub fn posture(mut self, posture: Posture) -> Policy {
615 self.posture = normalise_posture(posture);
616 self
617 }
618
619 /// Set what a payload that is not ER7 gets (spec §2.8).
620 ///
621 /// [`Unrecognised::Apply`] with an action that writes nothing —
622 /// [`Action::Keep`] or [`Action::Null`] — is normalised to
623 /// [`Unrecognised::Pass`], which is what it does.
624 #[must_use]
625 pub fn on_unrecognised(mut self, unrecognised: Unrecognised) -> Policy {
626 self.unrecognised = normalise_unrecognised(unrecognised);
627 self
628 }
629
630 /// Read a policy file (spec §6).
631 ///
632 /// Blank lines and `#` comments are ignored; every other line is
633 /// either a path, whitespace, and an action, in the order they apply,
634 /// or one of the three reserved first words — `accept`, `reject`, and
635 /// `unrecognised` — that set what the policy does by default.
636 ///
637 /// A file that states no defaults accepts by default and **refuses** a
638 /// payload that is not ER7: unlike [`Policy::accept_all`], a file was
639 /// written by somebody who may simply not have considered one, and
640 /// refusing is the answer that cannot lose a value quietly.
641 ///
642 /// Example:
643 ///
644 /// ```
645 /// # fn main() -> Result<(), er7_redact::Error> {
646 /// use er7_redact::{Action, Policy, Posture, Unrecognised};
647 ///
648 /// let policy = Policy::parse("
649 /// MSH keep # everything but the header...
650 /// OBX-5 keep # ...and the numbers the test asserts on
651 ///
652 /// reject replace REDACTED
653 /// unrecognised mask *
654 /// ")?;
655 ///
656 /// assert_eq!(policy.rules.len(), 2);
657 /// assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
658 /// assert_eq!(policy.unrecognised, Unrecognised::Apply(Action::Mask('*')));
659 ///
660 /// // A file that says nothing accepts, and refuses what it cannot read.
661 /// let quiet = Policy::parse("PID-5 clear")?;
662 /// assert_eq!(quiet.posture, Posture::Accept);
663 /// assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
664 ///
665 /// // A malformed line names itself.
666 /// let e = Policy::parse("PID-5 obfuscate").unwrap_err();
667 /// assert_eq!(e.to_string(), "policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"");
668 /// # Ok(())
669 /// # }
670 /// ```
671 ///
672 /// # Errors
673 ///
674 /// [`Error::BadPolicy`] naming the **line number**, the line, and the
675 /// problem. Reading a policy is the one place this crate is strict,
676 /// because a typo means a value that silently was not redacted (spec
677 /// §6.4).
678 pub fn parse(text: &str) -> Result<Policy, Error> {
679 let mut policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
680 for (index, line) in text.lines().enumerate() {
681 // A `#` starts a comment wherever it appears, so replacement
682 // text cannot contain one (spec §16.4).
683 let line = match line.split_once('#') {
684 Some((before, _comment)) => before,
685 None => line,
686 }
687 .trim();
688 if line.is_empty() {
689 continue;
690 }
691 let number = index + 1;
692 let at = |e: Error| Error::BadPolicy(format!("policy line {number}: {line:?}: {e}"));
693
694 // The reserved words come first, and a line is one or the
695 // other: three characters is the whole of a segment name, so
696 // none of them can be a path (spec §6.3).
697 let (word, argument) = match split_line(line) {
698 Some((word, argument)) => (word, argument),
699 None => (line, ""),
700 };
701 let lowercase = word.to_ascii_lowercase();
702 match lowercase.as_str() {
703 ACCEPT | REJECT => {
704 // A second one replaces the first rather than being
705 // ignored: a policy has one posture, and quietly
706 // keeping the earlier one would hide an editing
707 // mistake (spec §6.3).
708 policy.posture = Posture::parse(&lowercase, argument).map_err(at)?;
709 continue;
710 }
711 UNRECOGNISED | UNRECOGNIZED => {
712 policy.unrecognised = Unrecognised::parse(argument).map_err(at)?;
713 continue;
714 }
715 REMOVED_FALLBACK => {
716 // Refused rather than read as a synonym: `*` never
717 // said which of the two postures it meant (spec §6.3).
718 let replacement = match argument {
719 "" | "keep" => ACCEPT.to_string(),
720 action => format!("{REJECT} {action}"),
721 };
722 return Err(at(Error::BadPolicy(format!(
723 "the default line is now {replacement:?}, not {REMOVED_FALLBACK:?}"
724 ))));
725 }
726 _ => {}
727 }
728
729 if argument.is_empty() {
730 return Err(at(Error::BadPolicy(
731 "expected a path and an action".to_string(),
732 )));
733 }
734 let action = Action::parse(argument).map_err(at)?;
735 policy.rules.push(Rule::new(word, action).map_err(at)?);
736 }
737 Ok(policy)
738 }
739
740 /// Append another policy's rules, take the stricter posture, and
741 /// take the appended policy's disposition for an unrecognised payload
742 /// (D20, spec §2.6).
743 ///
744 /// This is how the command line concatenates several `--policy` files
745 /// and `--rule` arguments; order is significant for the rules (D7).
746 ///
747 /// The **posture** cannot be weakened by appending, and deliberately:
748 /// a file of extra rules says nothing about its posture, so it accepts
749 /// by default, and adopting that would switch redaction off for
750 /// everything the file did not name. Silence is indistinguishable from
751 /// a decision, so silence is not trusted. To relax a posture, say so
752 /// with [`Policy::posture`].
753 ///
754 /// The disposition for an **unrecognised payload** is different, and
755 /// the appended policy's wins outright. Nothing is silent there:
756 /// [`Policy::parse`] gives a file that says nothing the strictest
757 /// disposition there is, [`Unrecognised::Refuse`], so every value one
758 /// carries is somebody's decision — and a file that goes to the
759 /// trouble of writing `unrecognised pass` should not be quietly
760 /// overruled by a default it never saw.
761 ///
762 /// Example:
763 ///
764 /// ```
765 /// # fn main() -> Result<(), er7_redact::Error> {
766 /// use er7_redact::{Action, Policy, Posture};
767 ///
768 /// let mut policy = Policy::all_but_the_header();
769 /// policy.append(Policy::parse("OBX-2 keep")?);
770 ///
771 /// // The appended file accepts by default; the strict policy still rejects.
772 /// assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
773 ///
774 /// // And a stricter action in the appended policy does win.
775 /// policy.append(Policy::parse("reject clear")?);
776 /// assert_eq!(policy.posture, Posture::Reject(Action::Clear));
777 /// # Ok(())
778 /// # }
779 /// ```
780 pub fn append(&mut self, other: Policy) {
781 self.rules.extend(other.rules);
782 if other.posture.strictness() >= self.posture.strictness() {
783 self.posture = other.posture;
784 }
785 self.unrecognised = other.unrecognised;
786 }
787
788 /// True when the policy would redact nothing at all: no rules, and it
789 /// accepts by default.
790 ///
791 /// What it does with an unrecognised payload is not part of this: a
792 /// policy that refuses one still redacts nothing.
793 #[must_use]
794 pub fn is_empty(&self) -> bool {
795 self.rules.is_empty() && self.posture == Posture::Accept
796 }
797}
798
799impl fmt::Display for Policy {
800 /// The canonical policy file (spec §6.5): one rule per line, paths
801 /// padded to a common width, then the two default lines — always
802 /// both, whatever they say, so that a reader never has to know which
803 /// default was the quiet one.
804 ///
805 /// Example:
806 ///
807 /// ```
808 /// # fn main() -> Result<(), er7_redact::Error> {
809 /// use er7_redact::{Action, Policy};
810 ///
811 /// let policy = Policy::accept_all()
812 /// .with("PID-5", Action::redacted())?
813 /// .with("PID-7", Action::First(4))?
814 /// .posture(er7_redact::Posture::Reject(Action::Clear));
815 ///
816 /// assert_eq!(policy.to_string(), "\
817 /// PID-5 replace REDACTED
818 /// PID-7 first 4
819 ///
820 /// reject clear
821 /// unrecognised pass
822 /// ");
823 ///
824 /// // And it reads back as the same policy.
825 /// assert_eq!(Policy::parse(&policy.to_string())?, policy);
826 /// # Ok(())
827 /// # }
828 /// ```
829 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
830 let width = self
831 .rules
832 .iter()
833 .map(|rule| rule.path.to_string().len())
834 .max()
835 .unwrap_or(0);
836 for rule in &self.rules {
837 let path = rule.path.to_string();
838 writeln!(f, "{path:<width$} {}", rule.action)?;
839 }
840 if !self.rules.is_empty() {
841 writeln!(f)?;
842 }
843 writeln!(f, "{}", self.posture)?;
844 writeln!(f, "{UNRECOGNISED:<DEFAULT_WIDTH$} {}", self.unrecognised)
845 }
846}
847
848/// Split a policy line into its first word and the rest, or `None` when it
849/// has only the one.
850fn split_line(line: &str) -> Option<(&str, &str)> {
851 let line = line.trim();
852 let (path, action) = line.split_once(char::is_whitespace)?;
853 let action = action.trim();
854 if action.is_empty() {
855 None
856 } else {
857 Some((path, action))
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 #[test]
866 fn a_policy_round_trips_through_display() {
867 // D18: the file format is a compatibility surface, so a policy
868 // written out must read back as the same policy (spec §6.5).
869 for policy in [
870 Policy::accept_all(),
871 Policy::reject_all(),
872 Policy::patient_identifiers(),
873 Policy::all_but_the_header(),
874 Policy::accept_all()
875 .posture(Posture::Reject(Action::Null))
876 .on_unrecognised(Unrecognised::Apply(Action::First(4))),
877 ] {
878 assert_eq!(Policy::parse(&policy.to_string()).unwrap(), policy);
879 }
880 }
881
882 #[test]
883 fn parses_comments_blank_lines_and_the_defaults() {
884 let policy = Policy::parse(
885 "\
886 # a comment on its own line\n\
887 \n\
888 PID-5 replace REDACTED # and one after a rule\n\
889 \t OBX-5 keep \n\
890 REJECT clear\n\
891 Unrecognized pass\n",
892 )
893 .unwrap();
894 assert_eq!(policy.rules.len(), 2);
895 assert_eq!(policy.rules[0].action, Action::redacted());
896 assert_eq!(policy.rules[1].path.to_string(), "OBX-5");
897 assert_eq!(policy.posture, Posture::Reject(Action::Clear));
898 assert_eq!(policy.unrecognised, Unrecognised::Pass);
899
900 // A later default line replaces an earlier one (spec §6.3).
901 let replaced = Policy::parse("reject clear\nreject replace X").unwrap();
902 assert_eq!(
903 replaced.posture,
904 Posture::Reject(Action::Replace("X".to_string()))
905 );
906
907 // A bare `reject` is the placeholder the built-ins write, and
908 // rejecting by keeping is accepting.
909 assert_eq!(
910 Policy::parse("reject").unwrap().posture,
911 Posture::Reject(Action::redacted())
912 );
913 assert_eq!(
914 Policy::parse("reject keep").unwrap().posture,
915 Posture::Accept
916 );
917 assert_eq!(
918 Policy::parse("unrecognised null").unwrap().unrecognised,
919 Unrecognised::Pass
920 );
921
922 // A file that says nothing accepts, and refuses what it cannot read.
923 let quiet = Policy::parse("PID-5 clear").unwrap();
924 assert_eq!(quiet.posture, Posture::Accept);
925 assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
926 }
927
928 #[test]
929 fn reports_a_bad_policy_line() {
930 // D15: reading a policy is the one place this crate is strict,
931 // because a typo means a value that silently was not redacted
932 // (spec §6.4).
933 let cases = [
934 (
935 "PID-5 obfuscate",
936 "policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"",
937 ),
938 (
939 "MSH keep\nPID-0 clear",
940 "policy line 2: \"PID-0 clear\": invalid HL7 path \"PID-0\": \
941 indices are 1-based, so 0 is not a position",
942 ),
943 (
944 "PID-5",
945 "policy line 1: \"PID-5\": expected a path and an action",
946 ),
947 (
948 "# comment\n\nPID-7 first three",
949 "policy line 3: \"PID-7 first three\": action \"first\" wants a number \
950 of characters, not \"three\"",
951 ),
952 (
953 "accept everything",
954 "policy line 1: \"accept everything\": \"accept\" takes no argument, \
955 but got \"everything\"",
956 ),
957 (
958 "unrecognised",
959 "policy line 1: \"unrecognised\": \"unrecognised\" wants \"refuse\", \
960 \"pass\", or an action",
961 ),
962 (
963 "unrecognised sideways",
964 "policy line 1: \"unrecognised sideways\": unknown action \"sideways\"",
965 ),
966 // The `*` line of 0.1, refused with its replacement (spec §6.3).
967 (
968 "MSH keep\n* replace REDACTED",
969 "policy line 2: \"* replace REDACTED\": the default line is now \
970 \"reject replace REDACTED\", not \"*\"",
971 ),
972 (
973 "* keep",
974 "policy line 1: \"* keep\": the default line is now \"accept\", not \"*\"",
975 ),
976 ];
977 for (text, expected) in cases {
978 let error = Policy::parse(text).unwrap_err();
979 assert_eq!(error.to_string(), expected, "parsing {text:?}");
980 }
981 }
982
983 #[test]
984 fn the_default_policy_names_the_documented_positions() {
985 // D14: the table in spec §5.1 is normative, and this is its
986 // executable form; the two change together. A test cannot say the
987 // list is *sufficient* — no test can (spec §5.5) — only that it is
988 // the list the spec documents.
989 let policy = Policy::patient_identifiers();
990 assert_eq!(policy.rules.len(), 40);
991 assert_eq!(policy.posture, Posture::Accept);
992 assert_eq!(policy.unrecognised, Unrecognised::Refuse);
993
994 let named: Vec<String> = policy.rules.iter().map(|r| r.path.to_string()).collect();
995 for path in [
996 "PID-3.1", "PID-5", "PID-7", "PID-11", "PID-19", "NK1-2", "GT1-3", "IN1-16",
997 ] {
998 assert!(named.contains(&path.to_string()), "missing {path}");
999 }
1000 // Deliberately absent: free text and quasi-identifiers (spec §5.4).
1001 for path in ["NTE-3", "OBX-5", "PID-8", "PID-10", "MSH-4"] {
1002 assert!(!named.contains(&path.to_string()), "unexpected {path}");
1003 }
1004
1005 assert!(Policy::accept_all().is_empty());
1006 assert!(!Policy::reject_all().is_empty());
1007 }
1008
1009 #[test]
1010 fn the_two_bare_postures_say_what_they_are() {
1011 // Spec §5.6: no rules and no field table, and the two defaults
1012 // that match what each one claims about itself.
1013 let accept = Policy::accept_all();
1014 assert!(accept.rules.is_empty());
1015 assert_eq!(accept.posture, Posture::Accept);
1016 assert_eq!(accept.unrecognised, Unrecognised::Pass);
1017
1018 let reject = Policy::reject_all();
1019 assert!(reject.rules.is_empty());
1020 assert_eq!(reject.posture, Posture::Reject(Action::redacted()));
1021 assert_eq!(reject.unrecognised, Unrecognised::Apply(Action::Mask('*')));
1022
1023 // The curated one is the same posture, with the header kept and a
1024 // refusal in place of the mask (spec §5.2).
1025 let curated = Policy::all_but_the_header();
1026 assert_eq!(curated.posture, reject.posture);
1027 assert_eq!(curated.unrecognised, Unrecognised::Refuse);
1028 assert_eq!(curated.rules.len(), 1);
1029 assert_eq!(curated.rules[0].to_string(), "MSH keep");
1030 }
1031
1032 #[test]
1033 fn appends_in_order() {
1034 let mut policy = Policy::accept_all()
1035 .with("PID-5", Action::redacted())
1036 .unwrap();
1037 policy.append(Policy::parse("PID-7 first 4\nreject clear").unwrap());
1038 assert_eq!(policy.rules.len(), 2);
1039 assert_eq!(policy.rules[1].action, Action::First(4));
1040 assert_eq!(policy.posture, Posture::Reject(Action::Clear));
1041 }
1042
1043 #[test]
1044 fn appending_never_weakens_the_defaults() {
1045 // D20: a file of extra rules says nothing about its posture, so it
1046 // accepts by default. Adopting that would switch redaction off for
1047 // everything the file did not name — the one failure spec §1.5
1048 // puts first (spec §2.6).
1049 let mut strict = Policy::all_but_the_header();
1050 strict.append(Policy::parse("OBX-2 keep").unwrap());
1051 assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
1052 assert_eq!(strict.unrecognised, Unrecognised::Refuse);
1053
1054 // Even when the appended policy says `accept` outright.
1055 let mut strict = Policy::all_but_the_header();
1056 strict.append(Policy::parse("accept").unwrap());
1057 assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
1058
1059 // A file that says nothing about an unrecognised payload is given
1060 // the strictest disposition when it is read, so adopting it can
1061 // only tighten a rejecting policy that masked one.
1062 let mut masking = Policy::reject_all();
1063 masking.append(Policy::parse("OBX-2 keep").unwrap());
1064 assert_eq!(masking.unrecognised, Unrecognised::Refuse);
1065
1066 // But a file that asks for one outright is not overruled: nothing
1067 // else in the run said anything about it.
1068 let mut masking = Policy::reject_all();
1069 masking.append(Policy::parse("unrecognised pass").unwrap());
1070 assert_eq!(masking.unrecognised, Unrecognised::Pass);
1071
1072 // Strictening works, in both directions of travel.
1073 // `mask #` is unwritable in a file — a `#` starts a comment
1074 // wherever it appears (spec §16.4) — so this uses another one.
1075 let mut lax = Policy::accept_all();
1076 lax.append(Policy::parse("reject mask X\nunrecognised refuse").unwrap());
1077 assert_eq!(lax.posture, Posture::Reject(Action::Mask('X')));
1078 assert_eq!(lax.unrecognised, Unrecognised::Refuse);
1079
1080 // And relaxing one is done deliberately, which is the only way.
1081 let relaxed = Policy::all_but_the_header().posture(Posture::Accept);
1082 assert_eq!(relaxed.posture, Posture::Accept);
1083 }
1084}