use headwater_yaml::Value;
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Observation {
Control { control: String, commit: String },
Verification {
verification: String,
commit: String,
criterion_digest: String,
},
}
impl Observation {
pub fn id(&self) -> &str {
match self {
Observation::Control { control, .. } => control,
Observation::Verification { verification, .. } => verification,
}
}
pub fn commit(&self) -> &str {
match self {
Observation::Control { commit, .. } => commit,
Observation::Verification { commit, .. } => commit,
}
}
fn population(&self) -> &'static str {
match self {
Observation::Control { .. } => "control",
Observation::Verification { .. } => "verification",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Recorded<'a> {
Absent,
Entry {
commit: &'a str,
criterion_digest: &'a str,
},
Unread(&'a str),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Problem {
File(String),
Entry {
id: String,
reason: String,
verification: bool,
},
}
#[derive(Clone, Debug, Default)]
pub struct Observations {
entries: Vec<Observation>,
problems: Vec<Problem>,
presence: Presence,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
enum Presence {
#[default]
Absent,
Unreadable,
Read(String),
}
pub const PATH: &str = ".headwater/observations.yml";
const FILE: &str = "observations.yml";
const READ_AS_CONTROL: &str = "it names no `kind: verification`, so it read as a control";
fn plausible_commit(commit: &str) -> bool {
!commit.is_empty()
&& commit.len() <= 40
&& commit.chars().all(|c| c.is_ascii_hexdigit())
&& commit.len() >= 4
}
impl Observations {
pub fn empty() -> Self {
Observations::default()
}
pub fn of(entries: Vec<Observation>) -> Self {
Observations {
entries,
problems: Vec::new(),
presence: Presence::Absent,
}
}
pub fn at(root: &Path) -> Self {
let path = root.join(".headwater").join(FILE);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Observations::empty();
}
Err(error) => {
return Observations {
entries: Vec::new(),
problems: vec![Problem::File(format!(
"`.headwater/{FILE}` exists and could not be read: {error}. Every \
control or verification it would have named reads unobserved rather \
than verified."
))],
presence: Presence::Unreadable,
};
}
};
let presence = Presence::Read(headwater_hash::digest(text.as_bytes()));
let parsed = match headwater_yaml::load(&text) {
Ok(parsed) => parsed,
Err(errors) => {
let message = headwater_yaml::error::render(&errors);
return Observations {
entries: Vec::new(),
problems: vec![Problem::File(format!(
"`.headwater/{FILE}` did not parse as YAML: {}. Every control or \
verification it would have named reads unobserved rather than \
verified.",
message.trim_end().replace('\n', "; ")
))],
presence,
};
}
};
let Value::Map(map) = &parsed.value else {
return Observations {
entries: Vec::new(),
problems: vec![Problem::File(format!(
"`.headwater/{FILE}`'s top level is not a mapping of an identifier to an \
entry. Every control or verification it would have named reads unobserved \
rather than verified."
))],
presence,
};
};
let mut entries: Vec<Observation> = Vec::new();
let mut problems = Vec::new();
for entry in map {
let id = &entry.key.value;
let Value::Map(fields) = &entry.value.value else {
problems.push(Problem::Entry {
id: id.clone(),
verification: false,
reason: "it names no mapping under the identifier".to_string(),
});
continue;
};
let text_field = |name: &str| {
fields
.get(name)
.and_then(|node| node.value.as_scalar())
.map(|scalar| scalar.text.clone())
};
let kind = text_field("kind").unwrap_or_else(|| "control".to_string());
match kind.as_str() {
"control" => {
let Some(commit) = text_field("commit") else {
problems.push(Problem::Entry {
id: id.clone(),
verification: false,
reason: "it names no `commit`".to_string(),
});
continue;
};
entries.push(Observation::Control {
control: id.clone(),
commit,
});
}
"verification" => {
let Some(commit) = text_field("commit") else {
problems.push(Problem::Entry {
id: id.clone(),
verification: true,
reason: "it names no `commit`".to_string(),
});
continue;
};
let Some(criterion_digest) = text_field("criterion_digest") else {
problems.push(Problem::Entry {
id: id.clone(),
verification: true,
reason: "it names no `criterion_digest`".to_string(),
});
continue;
};
if criterion_digest.trim().is_empty() {
problems.push(Problem::Entry {
id: id.clone(),
verification: true,
reason: "its `criterion_digest` is empty".to_string(),
});
continue;
}
if !plausible_commit(&commit) {
problems.push(Problem::Entry {
id: id.clone(),
verification: true,
reason: format!(
"its `commit` (`{commit}`) does not read as a plausible commit \
reference, so it cannot be treated as observed"
),
});
continue;
}
entries.push(Observation::Verification {
verification: id.clone(),
commit,
criterion_digest,
});
}
other => {
problems.push(Problem::Entry {
id: id.clone(),
verification: false,
reason: format!(
"it names `kind: {other}`, which is neither `control` nor \
`verification`"
),
});
}
}
}
Observations {
entries,
problems,
presence,
}
}
pub fn observed(&self, control: &str) -> bool {
self.entries
.iter()
.any(|entry| matches!(entry, Observation::Control { control: id, .. } if id == control))
}
pub fn verification(&self, id: &str) -> Option<(&str, &str)> {
self.entries.iter().find_map(|entry| match entry {
Observation::Verification {
verification,
commit,
criterion_digest,
} if verification == id => Some((commit.as_str(), criterion_digest.as_str())),
_ => None,
})
}
pub fn recorded(&self, id: &str) -> Recorded<'_> {
if let Some(Problem::File(reason)) = self
.problems
.iter()
.find(|problem| matches!(problem, Problem::File(_)))
{
return Recorded::Unread(reason);
}
if let Some((commit, criterion_digest)) = self.verification(id) {
return Recorded::Entry {
commit,
criterion_digest,
};
}
if let Some(reason) = self.problems.iter().find_map(|problem| match problem {
Problem::Entry {
id: named, reason, ..
} if named == id => Some(reason.as_str()),
_ => None,
}) {
return Recorded::Unread(reason);
}
if self.observed(id) {
return Recorded::Unread(READ_AS_CONTROL);
}
Recorded::Absent
}
pub fn unread(&self) -> Option<&str> {
self.problems.iter().find_map(|problem| match problem {
Problem::File(reason) => Some(reason.as_str()),
Problem::Entry { .. } => None,
})
}
pub fn entries(&self) -> &[Observation] {
&self.entries
}
pub fn problems(&self) -> &[Problem] {
&self.problems
}
pub fn read_set_digest(&self) -> Option<Option<&str>> {
match &self.presence {
Presence::Absent => None,
Presence::Unreadable => Some(None),
Presence::Read(digest) => Some(Some(digest.as_str())),
}
}
#[cfg(test)]
pub fn malformed_for_test(problem: String) -> Self {
Observations {
entries: Vec::new(),
problems: vec![Problem::File(problem)],
presence: Presence::Unreadable,
}
}
}
pub(crate) fn duplicate_ids(entries: &[Observation]) -> Vec<String> {
let mut duplicate = Vec::new();
for (at, entry) in entries.iter().enumerate() {
let first_at = entries
.iter()
.position(|earlier| {
earlier.id() == entry.id() && earlier.population() == entry.population()
})
.expect("the entry itself is in its own list");
if first_at != at && !duplicate.contains(&entry.id().to_string()) {
duplicate.push(entry.id().to_string());
}
}
duplicate
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for Scratch {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for Scratch {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for Scratch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
#[test]
fn a_control_the_snapshot_names_is_observed() {
let observations = Observations::of(vec![Observation::Control {
control: "CT-EXT-1".to_string(),
commit: "788885a9".to_string(),
}]);
assert!(observations.observed("CT-EXT-1"));
assert!(!observations.observed("CT-EXT-2"));
}
#[test]
fn a_constructed_snapshot_reads_as_absent_for_the_read_set() {
let observations = Observations::of(vec![]);
assert_eq!(observations.read_set_digest(), None);
assert_eq!(Observations::empty().read_set_digest(), None);
}
#[test]
fn no_file_is_no_observation() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-empty",
std::process::id()
)));
let _ = std::fs::create_dir_all(&dir);
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert!(observations.problems().is_empty());
assert_eq!(observations.read_set_digest(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_committed_file_reads_the_control_and_the_commit_it_ran_against() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-present",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"CT-EXT-1:\n commit: 788885a9\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.observed("CT-EXT-1"));
assert_eq!(observations.entries()[0].commit(), "788885a9");
assert!(observations.problems().is_empty());
assert!(
matches!(observations.read_set_digest(), Some(Some(_))),
"a present, parsable file carries a digest a read set can use: {:?}",
observations.read_set_digest()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_that_does_not_parse_as_yaml_is_a_whole_file_problem_with_a_readable_message() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-badyaml",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"CT-EXT-1: [unterminated\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
let Problem::File(message) = &observations.problems()[0] else {
panic!(
"a whole-file failure is `Problem::File`: {:?}",
observations.problems()
);
};
assert!(message.contains(FILE));
assert!(
!message.contains("LoadError") && !message.contains("Span {"),
"{message}"
);
assert!(
matches!(observations.read_set_digest(), Some(Some(_))),
"{:?}",
observations.read_set_digest()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreadable_file_carries_no_digest_and_never_carries_under_a_gate() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-unreadable",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::create_dir_all(dir.join(".headwater").join(FILE)).expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert_eq!(observations.read_set_digest(), Some(None));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_whose_top_level_is_not_a_mapping_is_a_whole_file_problem() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-notmap",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"- CT-EXT-1\n- CT-EXT-2\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
assert!(matches!(observations.problems()[0], Problem::File(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_entry_with_no_commit_is_an_entry_problem_and_the_others_still_read() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-nocommit",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"CT-EXT-1:\n commit: 788885a9\nCT-EXT-2:\n posture: advisory\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.observed("CT-EXT-1"));
assert!(!observations.observed("CT-EXT-2"));
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
assert_eq!(
observations.problems()[0],
Problem::Entry {
id: "CT-EXT-2".to_string(),
reason: "it names no `commit`".to_string(),
verification: false,
}
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_literal_duplicate_key_is_a_whole_file_problem_via_the_loader() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-dup",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"CT-EXT-1:\n commit: aaa\nCT-EXT-1:\n commit: bbb\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
assert!(matches!(observations.problems()[0], Problem::File(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_verification_entry_reads_its_commit_and_criterion_digest_back() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-verification",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"FIX-VER-0001:\n kind: verification\n commit: 788885a9\n criterion_digest: \
abc123\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(
observations.problems().is_empty(),
"{:?}",
observations.problems()
);
assert_eq!(
observations.verification("FIX-VER-0001"),
Some(("788885a9", "abc123"))
);
assert_eq!(observations.verification("FIX-VER-9999"), None);
assert!(!observations.observed("FIX-VER-0001"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_verification_entry_with_no_criterion_digest_is_an_entry_problem() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-verification-nodigest",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"FIX-VER-0001:\n kind: verification\n commit: 788885a9\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert_eq!(observations.verification("FIX-VER-0001"), None);
assert_eq!(
observations.problems(),
&[Problem::Entry {
id: "FIX-VER-0001".to_string(),
reason: "it names no `criterion_digest`".to_string(),
verification: true,
}]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_verification_entry_with_an_implausible_commit_is_an_entry_problem() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-verification-badcommit",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"FIX-VER-0001:\n kind: verification\n commit: \"\"\n criterion_digest: abc123\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert_eq!(observations.verification("FIX-VER-0001"), None);
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
let Problem::Entry { reason, .. } = &observations.problems()[0] else {
panic!("{:?}", observations.problems());
};
assert!(reason.contains("plausible commit reference"), "{reason}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unrecognized_kind_is_an_entry_problem() {
let dir = Scratch(std::env::temp_dir().join(format!(
"headwater-observation-test-{}-badkind",
std::process::id()
)));
let _ = std::fs::create_dir_all(dir.join(".headwater"));
std::fs::write(
dir.join(".headwater").join(FILE),
"FIX-VER-0001:\n kind: unknown\n commit: 788885a9\n",
)
.expect("the fixture writes");
let observations = Observations::at(&dir);
assert!(observations.entries().is_empty());
assert_eq!(
observations.problems().len(),
1,
"{:?}",
observations.problems()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plausible_commit_accepts_hex_and_refuses_everything_else() {
assert!(plausible_commit("788885a9"));
assert!(plausible_commit(
"788885a9788885a9788885a9788885a9788885a9"[..40].as_ref()
));
assert!(!plausible_commit(""));
assert!(!plausible_commit("nonexistent-commit"));
assert!(!plausible_commit("abc")); assert!(!plausible_commit(&"a".repeat(41))); }
#[test]
fn duplicate_ids_reads_within_one_population_and_not_across_two() {
let entries = vec![
Observation::Control {
control: "SAME-ID".to_string(),
commit: "aaa1".to_string(),
},
Observation::Verification {
verification: "SAME-ID".to_string(),
commit: "bbb2".to_string(),
criterion_digest: "digest".to_string(),
},
Observation::Control {
control: "SAME-ID".to_string(),
commit: "ccc3".to_string(),
},
];
assert_eq!(duplicate_ids(&entries), vec!["SAME-ID".to_string()]);
}
}