use crate::{Action, Error};
use er7::Path;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub path: Path,
pub action: Action,
}
impl Rule {
pub fn new(path: &str, action: Action) -> Result<Rule, Error> {
Ok(Rule {
path: Path::parse(path)?,
action,
})
}
pub fn parse(line: &str) -> Result<Rule, Error> {
let at = |e: Error| Error::BadPolicy(format!("rule {:?}: {e}", line.trim()));
let Some((path, action)) = split_line(line) else {
return Err(at(Error::BadPolicy(
"expected a path and an action".to_string(),
)));
};
let action = Action::parse(action).map_err(at)?;
Rule::new(path, action).map_err(at)
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.path, self.action)
}
}
const ACCEPT: &str = "accept";
const REJECT: &str = "reject";
const UNRECOGNISED: &str = "unrecognised";
const UNRECOGNIZED: &str = "unrecognized";
const REMOVED_FALLBACK: &str = "*";
const DEFAULT_WIDTH: usize = UNRECOGNISED.len();
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Posture {
Accept,
Reject(Action),
}
impl Posture {
fn strictness(&self) -> u8 {
match self {
Posture::Accept => 0,
Posture::Reject(_) => 1,
}
}
fn parse(word: &str, argument: &str) -> Result<Posture, Error> {
if word == ACCEPT {
if argument.is_empty() {
Ok(Posture::Accept)
} else {
Err(Error::BadPolicy(format!(
"{ACCEPT:?} takes no argument, but got {argument:?}"
)))
}
} else if argument.is_empty() {
Ok(Posture::Reject(Action::redacted()))
} else {
Ok(normalise_posture(Posture::Reject(Action::parse(argument)?)))
}
}
}
impl fmt::Display for Posture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Posture::Accept => write!(f, "{ACCEPT}"),
Posture::Reject(action) => write!(f, "{REJECT:<DEFAULT_WIDTH$} {action}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unrecognised {
Pass,
Apply(Action),
Refuse,
}
impl Unrecognised {
fn parse(argument: &str) -> Result<Unrecognised, Error> {
match argument.to_ascii_lowercase().as_str() {
"" => Err(Error::BadPolicy(format!(
"{UNRECOGNISED:?} wants \"refuse\", \"pass\", or an action"
))),
"refuse" => Ok(Unrecognised::Refuse),
"pass" => Ok(Unrecognised::Pass),
_ => Ok(normalise_unrecognised(Unrecognised::Apply(Action::parse(
argument,
)?))),
}
}
}
impl fmt::Display for Unrecognised {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Unrecognised::Pass => write!(f, "pass"),
Unrecognised::Apply(action) => write!(f, "{action}"),
Unrecognised::Refuse => write!(f, "refuse"),
}
}
}
fn normalise_posture(posture: Posture) -> Posture {
match posture {
Posture::Reject(Action::Keep) => Posture::Accept,
posture => posture,
}
}
fn normalise_unrecognised(unrecognised: Unrecognised) -> Unrecognised {
match unrecognised {
Unrecognised::Apply(Action::Keep | Action::Null) => Unrecognised::Pass,
unrecognised => unrecognised,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub rules: Vec<Rule>,
pub posture: Posture,
pub unrecognised: Unrecognised,
}
impl Policy {
#[must_use]
pub fn accept_all() -> Policy {
Policy {
rules: Vec::new(),
posture: Posture::Accept,
unrecognised: Unrecognised::Pass,
}
}
#[must_use]
pub fn reject_all() -> Policy {
Policy {
rules: Vec::new(),
posture: Posture::Reject(Action::redacted()),
unrecognised: Unrecognised::Apply(Action::Mask('*')),
}
}
#[must_use]
pub fn patient_identifiers() -> Policy {
let table: &[(&str, Action)] = &[
("PID-2.1", Action::Pseudonym),
("PID-3.1", Action::Pseudonym),
("PID-4.1", Action::Pseudonym),
("PID-5", Action::redacted()),
("PID-6", Action::redacted()),
("PID-7", Action::First(4)),
("PID-9", Action::redacted()),
("PID-11", Action::Clear),
("PID-12", Action::Clear),
("PID-13", Action::Clear),
("PID-14", Action::Clear),
("PID-18.1", Action::Pseudonym),
("PID-19", Action::Clear),
("PID-20", Action::Clear),
("PID-21.1", Action::Pseudonym),
("PID-23", Action::Clear),
("PID-29", Action::First(4)),
("NK1-2", Action::redacted()),
("NK1-4", Action::Clear),
("NK1-5", Action::Clear),
("NK1-6", Action::Clear),
("PV1-5.1", Action::Pseudonym),
("PV1-7", Action::redacted()),
("PV1-8", Action::redacted()),
("PV1-9", Action::redacted()),
("PV1-17", Action::redacted()),
("PV1-19.1", Action::Pseudonym),
("GT1-2.1", Action::Pseudonym),
("GT1-3", Action::redacted()),
("GT1-4", Action::redacted()),
("GT1-5", Action::Clear),
("GT1-6", Action::Clear),
("GT1-7", Action::Clear),
("GT1-8", Action::First(4)),
("GT1-12", Action::Clear),
("IN1-16", Action::redacted()),
("IN1-18", Action::First(4)),
("IN1-19", Action::Clear),
("IN1-36", Action::Pseudonym),
("IN1-49.1", Action::Pseudonym),
];
let rules = table
.iter()
.map(|(path, action)| {
Rule::new(path, action.clone()).expect("built-in paths are well-formed")
})
.collect();
Policy {
rules,
posture: Posture::Accept,
unrecognised: Unrecognised::Refuse,
}
}
#[must_use]
pub fn all_but_the_header() -> Policy {
Policy::reject_all()
.with("MSH", Action::Keep)
.expect("built-in paths are well-formed")
.on_unrecognised(Unrecognised::Refuse)
}
pub fn with(mut self, path: &str, action: Action) -> Result<Policy, Error> {
self.rules.push(Rule::new(path, action)?);
Ok(self)
}
#[must_use]
pub fn posture(mut self, posture: Posture) -> Policy {
self.posture = normalise_posture(posture);
self
}
#[must_use]
pub fn on_unrecognised(mut self, unrecognised: Unrecognised) -> Policy {
self.unrecognised = normalise_unrecognised(unrecognised);
self
}
pub fn parse(text: &str) -> Result<Policy, Error> {
let mut policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
for (index, line) in text.lines().enumerate() {
let line = match line.split_once('#') {
Some((before, _comment)) => before,
None => line,
}
.trim();
if line.is_empty() {
continue;
}
let number = index + 1;
let at = |e: Error| Error::BadPolicy(format!("policy line {number}: {line:?}: {e}"));
let (word, argument) = match split_line(line) {
Some((word, argument)) => (word, argument),
None => (line, ""),
};
let lowercase = word.to_ascii_lowercase();
match lowercase.as_str() {
ACCEPT | REJECT => {
policy.posture = Posture::parse(&lowercase, argument).map_err(at)?;
continue;
}
UNRECOGNISED | UNRECOGNIZED => {
policy.unrecognised = Unrecognised::parse(argument).map_err(at)?;
continue;
}
REMOVED_FALLBACK => {
let replacement = match argument {
"" | "keep" => ACCEPT.to_string(),
action => format!("{REJECT} {action}"),
};
return Err(at(Error::BadPolicy(format!(
"the default line is now {replacement:?}, not {REMOVED_FALLBACK:?}"
))));
}
_ => {}
}
if argument.is_empty() {
return Err(at(Error::BadPolicy(
"expected a path and an action".to_string(),
)));
}
let action = Action::parse(argument).map_err(at)?;
policy.rules.push(Rule::new(word, action).map_err(at)?);
}
Ok(policy)
}
pub fn append(&mut self, other: Policy) {
self.rules.extend(other.rules);
if other.posture.strictness() >= self.posture.strictness() {
self.posture = other.posture;
}
self.unrecognised = other.unrecognised;
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty() && self.posture == Posture::Accept
}
}
impl fmt::Display for Policy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let width = self
.rules
.iter()
.map(|rule| rule.path.to_string().len())
.max()
.unwrap_or(0);
for rule in &self.rules {
let path = rule.path.to_string();
writeln!(f, "{path:<width$} {}", rule.action)?;
}
if !self.rules.is_empty() {
writeln!(f)?;
}
writeln!(f, "{}", self.posture)?;
writeln!(f, "{UNRECOGNISED:<DEFAULT_WIDTH$} {}", self.unrecognised)
}
}
fn split_line(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
let (path, action) = line.split_once(char::is_whitespace)?;
let action = action.trim();
if action.is_empty() {
None
} else {
Some((path, action))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_policy_round_trips_through_display() {
for policy in [
Policy::accept_all(),
Policy::reject_all(),
Policy::patient_identifiers(),
Policy::all_but_the_header(),
Policy::accept_all()
.posture(Posture::Reject(Action::Null))
.on_unrecognised(Unrecognised::Apply(Action::First(4))),
] {
assert_eq!(Policy::parse(&policy.to_string()).unwrap(), policy);
}
}
#[test]
fn parses_comments_blank_lines_and_the_defaults() {
let policy = Policy::parse(
"\
# a comment on its own line\n\
\n\
PID-5 replace REDACTED # and one after a rule\n\
\t OBX-5 keep \n\
REJECT clear\n\
Unrecognized pass\n",
)
.unwrap();
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.rules[0].action, Action::redacted());
assert_eq!(policy.rules[1].path.to_string(), "OBX-5");
assert_eq!(policy.posture, Posture::Reject(Action::Clear));
assert_eq!(policy.unrecognised, Unrecognised::Pass);
let replaced = Policy::parse("reject clear\nreject replace X").unwrap();
assert_eq!(
replaced.posture,
Posture::Reject(Action::Replace("X".to_string()))
);
assert_eq!(
Policy::parse("reject").unwrap().posture,
Posture::Reject(Action::redacted())
);
assert_eq!(
Policy::parse("reject keep").unwrap().posture,
Posture::Accept
);
assert_eq!(
Policy::parse("unrecognised null").unwrap().unrecognised,
Unrecognised::Pass
);
let quiet = Policy::parse("PID-5 clear").unwrap();
assert_eq!(quiet.posture, Posture::Accept);
assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
}
#[test]
fn reports_a_bad_policy_line() {
let cases = [
(
"PID-5 obfuscate",
"policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"",
),
(
"MSH keep\nPID-0 clear",
"policy line 2: \"PID-0 clear\": invalid HL7 path \"PID-0\": \
indices are 1-based, so 0 is not a position",
),
(
"PID-5",
"policy line 1: \"PID-5\": expected a path and an action",
),
(
"# comment\n\nPID-7 first three",
"policy line 3: \"PID-7 first three\": action \"first\" wants a number \
of characters, not \"three\"",
),
(
"accept everything",
"policy line 1: \"accept everything\": \"accept\" takes no argument, \
but got \"everything\"",
),
(
"unrecognised",
"policy line 1: \"unrecognised\": \"unrecognised\" wants \"refuse\", \
\"pass\", or an action",
),
(
"unrecognised sideways",
"policy line 1: \"unrecognised sideways\": unknown action \"sideways\"",
),
(
"MSH keep\n* replace REDACTED",
"policy line 2: \"* replace REDACTED\": the default line is now \
\"reject replace REDACTED\", not \"*\"",
),
(
"* keep",
"policy line 1: \"* keep\": the default line is now \"accept\", not \"*\"",
),
];
for (text, expected) in cases {
let error = Policy::parse(text).unwrap_err();
assert_eq!(error.to_string(), expected, "parsing {text:?}");
}
}
#[test]
fn the_default_policy_names_the_documented_positions() {
let policy = Policy::patient_identifiers();
assert_eq!(policy.rules.len(), 40);
assert_eq!(policy.posture, Posture::Accept);
assert_eq!(policy.unrecognised, Unrecognised::Refuse);
let named: Vec<String> = policy.rules.iter().map(|r| r.path.to_string()).collect();
for path in [
"PID-3.1", "PID-5", "PID-7", "PID-11", "PID-19", "NK1-2", "GT1-3", "IN1-16",
] {
assert!(named.contains(&path.to_string()), "missing {path}");
}
for path in ["NTE-3", "OBX-5", "PID-8", "PID-10", "MSH-4"] {
assert!(!named.contains(&path.to_string()), "unexpected {path}");
}
assert!(Policy::accept_all().is_empty());
assert!(!Policy::reject_all().is_empty());
}
#[test]
fn the_two_bare_postures_say_what_they_are() {
let accept = Policy::accept_all();
assert!(accept.rules.is_empty());
assert_eq!(accept.posture, Posture::Accept);
assert_eq!(accept.unrecognised, Unrecognised::Pass);
let reject = Policy::reject_all();
assert!(reject.rules.is_empty());
assert_eq!(reject.posture, Posture::Reject(Action::redacted()));
assert_eq!(reject.unrecognised, Unrecognised::Apply(Action::Mask('*')));
let curated = Policy::all_but_the_header();
assert_eq!(curated.posture, reject.posture);
assert_eq!(curated.unrecognised, Unrecognised::Refuse);
assert_eq!(curated.rules.len(), 1);
assert_eq!(curated.rules[0].to_string(), "MSH keep");
}
#[test]
fn appends_in_order() {
let mut policy = Policy::accept_all()
.with("PID-5", Action::redacted())
.unwrap();
policy.append(Policy::parse("PID-7 first 4\nreject clear").unwrap());
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.rules[1].action, Action::First(4));
assert_eq!(policy.posture, Posture::Reject(Action::Clear));
}
#[test]
fn appending_never_weakens_the_defaults() {
let mut strict = Policy::all_but_the_header();
strict.append(Policy::parse("OBX-2 keep").unwrap());
assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
assert_eq!(strict.unrecognised, Unrecognised::Refuse);
let mut strict = Policy::all_but_the_header();
strict.append(Policy::parse("accept").unwrap());
assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
let mut masking = Policy::reject_all();
masking.append(Policy::parse("OBX-2 keep").unwrap());
assert_eq!(masking.unrecognised, Unrecognised::Refuse);
let mut masking = Policy::reject_all();
masking.append(Policy::parse("unrecognised pass").unwrap());
assert_eq!(masking.unrecognised, Unrecognised::Pass);
let mut lax = Policy::accept_all();
lax.append(Policy::parse("reject mask X\nunrecognised refuse").unwrap());
assert_eq!(lax.posture, Posture::Reject(Action::Mask('X')));
assert_eq!(lax.unrecognised, Unrecognised::Refuse);
let relaxed = Policy::all_but_the_header().posture(Posture::Accept);
assert_eq!(relaxed.posture, Posture::Accept);
}
}