diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 02d4776..2e3c04f 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -116,6 +116,10 @@ pub(crate) mod tests { pub(crate) const INSTANCE: &str = "yggdrasil"; pub(crate) const OTHER: &str = "thought-cage"; + /// `INSTANCE`'s own length, differing in its last byte alone. + pub(crate) const NEAR: &str = "yggdrasix"; + /// `INSTANCE` whole, with more after it. + pub(crate) const PREFIXED: &str = "yggdrasil-two"; pub(crate) const CAMPAIGN: &str = "eureka-state"; pub(crate) fn slug(value: &str) -> Slug { @@ -320,6 +324,27 @@ pub(crate) mod tests { let history = HuginnMindRequest::History { instance: slug(OTHER), scope }; assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(foreign)); + // Two names a comparison could mistake for this mind's. `NEAR` is + // `INSTANCE`'s own length and differs in its last byte, so a check + // reading lengths, first bytes, or running only when the declared name + // is longer admits it; `PREFIXED` carries `INSTANCE` whole, so a prefix + // test admits it. Both are refused on the read side and the write side. + assert_eq!(NEAR.len(), INSTANCE.len()); + assert!(PREFIXED.starts_with(INSTANCE)); + for declared in [NEAR, PREFIXED] { + let refusal = MindRefusal::ForeignInstance { declared: declared.into(), mind: INSTANCE.into() }; + let query = HuginnMindRequest::Query { instance: slug(declared), query: PipelineQuery::default() }; + assert_eq!(daemon.handle(query, now()), HuginnMindResponse::Refused(refusal.clone()), "{declared}"); + let view = HuginnMindRequest::View { instance: slug(declared), id: writes[0].clone() }; + assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(refusal.clone()), "{declared}"); + let admit = HuginnMindRequest::Admit(batch(declared, vec![identity(declared)])); + assert_eq!( + daemon.handle(admit, now()), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)), + "{declared}" + ); + } + assert_eq!(status(&mut daemon).documents, 1, "nothing landed"); } diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 69b8d97..5797fe0 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -22,6 +22,14 @@ use crate::store::test_stores::MemoryStore; pub(crate) const INSTANCE: &str = "yggdrasil"; pub(crate) const OTHER_INSTANCE: &str = "thought-cage"; +/// A foreign instance of `INSTANCE`'s own length, differing in its last byte +/// alone: a comparison of lengths, of first bytes, or one gated on the +/// declared name being the longer admits it, and a comparison of values does +/// not. +pub(crate) const NEAR_INSTANCE: &str = "yggdrasix"; +/// A foreign instance carrying `INSTANCE` whole as its prefix, so a prefix +/// test admits it. +pub(crate) const PREFIXED_INSTANCE: &str = "yggdrasil-two"; pub(crate) const CAMPAIGN: &str = "eureka-state"; pub(crate) const REPO: &str = "GameCult/Epiphany"; pub(crate) const OTHER_REPO: &str = "GameCult/Huginn"; diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index bf40e16..78b54c8 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -405,9 +405,16 @@ mod tests { /// One check, one owner: the value admission refuses a foreign instance /// with is the value `require_instance` returns, so the daemon asking it /// for a read and `admit_steps` asking it for a write cannot disagree. + /// + /// The foreign names are chosen so the check cannot pass by resembling a + /// comparison. `NEAR_INSTANCE` is the mind's own length and shares all but + /// its last byte, so a check reading only the lengths, only the first byte, + /// or running only when the declared name is longer lets it through; + /// `PREFIXED_INSTANCE` has the mind's whole name as its prefix, so a check + /// asking whether one starts with the other lets that one through. #[test] fn require_instance_is_the_one_check_admission_and_the_daemon_share() { - use crate::fixtures::{OTHER_INSTANCE, now, provenance, seeded}; + use crate::fixtures::{NEAR_INSTANCE, OTHER_INSTANCE, PREFIXED_INSTANCE, now, provenance, seeded}; use crate::receipt::Faculty; let mut mind = seeded(); @@ -415,6 +422,17 @@ mod tests { assert_eq!(mind.require_instance(&slug(OTHER_INSTANCE)).err(), Some(foreign.clone())); assert_eq!(mind.require_instance(&slug(INSTANCE)), Ok(())); + assert_eq!(NEAR_INSTANCE.len(), INSTANCE.len(), "the near name is the mind's own length"); + assert_eq!(NEAR_INSTANCE[..INSTANCE.len() - 1], INSTANCE[..INSTANCE.len() - 1], "and differs in one byte"); + assert!(PREFIXED_INSTANCE.starts_with(INSTANCE), "the prefixed name carries the mind's whole name"); + for declared in [NEAR_INSTANCE, PREFIXED_INSTANCE] { + assert_eq!( + mind.require_instance(&slug(declared)).err(), + Some(MindRefusal::ForeignInstance { declared: declared.into(), mind: INSTANCE.into() }), + "{declared} is not {INSTANCE}" + ); + } + let batch = crate::admission::PipelineAdmissionBatch { instance: slug(OTHER_INSTANCE), provenance: provenance(Faculty::Hands), diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index 8d22bfa..bd61cf6 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -50,6 +50,42 @@ } @{ Id = 'D1L' + Rule = 'The instance check compares the names, not their lengths.' + Test = 'mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' + New = ' if declared.0.len() != self.instance().0.len() {' + } + @{ + Id = 'D1L2' + Rule = 'It compares the names whole, not their first byte.' + Test = 'mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' + New = ' if declared.0.as_bytes().first() != self.instance().0.as_bytes().first() {' + } + @{ + Id = 'D1L3' + Rule = 'It runs on every declared name, not only on one long enough to look suspicious.' + Test = 'mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' + New = ' if declared.0.len() > self.instance().0.len() && declared != self.instance() {' + } + @{ + Id = 'D1L4' + Rule = 'It compares the names, not whether one begins with the other.' + Test = 'mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share' + Command = 'cargo test -p huginn-mind --lib' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' + New = ' if !declared.0.starts_with(&self.instance().0) {' + } + @{ + Id = 'D1N' Rule = 'The refusal names which mind refused, so an agent can act on the field rather than guess.' Test = 'mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share' Command = 'cargo test -p huginn-mind --lib' @@ -76,6 +112,14 @@ Ok(()) '@ } + @{ + Id = 'D1DL' + Rule = 'The same comparison, reached the daemon''s way: a read naming a name of the mind''s own length is refused too.' + Test = 'daemon::tests::a_read_or_a_write_naming_another_instance_is_refused_by_the_mind_and_writes_nothing' + File = 'crates/huginn-mind/src/mind.rs' + Old = ' if declared != self.instance() {' + New = ' if declared.0.len() != self.instance().0.len() {' + } @{ Id = 'D2' Rule = 'Rulings 14 and 18 on the wire: the daemon passes the declared instance to the mind as the client sent it.'