From cc71a569dcc013a71fbc77b61c86668805c8aa8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 05:14:34 +0200 Subject: [PATCH] Fourth fix batch: fill N1 in its owner, and correct two ledger claims N1 (medium-low). Moves the epiphany-pipeline pin to the leaf's new commit (9d3a3efd), which opens Slug::validate_slug. Huginn's own cultcache-rs pin is untouched; cargo tree -d still carries exactly one cultcache-rs. Mind::require_instance now validates the declared name's grammar before comparing identity, so admission's A1 and every read refuse a bad name identically, through one door. Mind::open and open_with validate the declared instance before path_for ever runs, so a Slug carrying `..` and a path separator (Soul's outer/inner probe) is refused before any store is created, not after it has already escaped the state root. require_grammatical_instance and its daemon-side branch in daemon.rs are deleted: the throwaway PipelineInstance wrapper it built is no longer the only door onto the leaf's grammar. The refusal now names the field a caller actually held the declared name under (declared, or instance) and the whole declared name, not the old wrapper's instance.instance field or a single failing label. Adds a leaf-escape regression test pinning Soul's probe and asserting no store is created, and a fullwidth-instance test showing Admit and Query refuse identically. Updates the cut10 grammar-fold mutant (S3wide) to the new function shape and the affected daemon test to the new field name. N5 and N6, prose only. The cut10 entries gain a note by S1 and S1d: neither mutant "dies on all three" faults its shared fixture carries, contrary to an earlier claim; S1d exempts the orphan fault by construction and is killed by the undecodable-document block instead, the next one the test body reaches. The cut9 header's self-contradiction at lines 18-25 is resolved (nothing in the suite exercises the door, not "nothing reaches it"), its self-reference now points at store.rs:18-19, and its line-1 range is corrected to V1-V24L. All three suites rerun clean after the pin move: cut8 67/67, cut9 38/38, cut10 65/65, every M0 green, restores hash-verified. cargo test --workspace: huginn-mind 61/61 (was 60), huginn-daemon 18/18 (was 17). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 +- crates/huginn-daemon/src/daemon.rs | 48 +++++++++++++----- crates/huginn-mind/Cargo.toml | 2 +- crates/huginn-mind/src/lib.rs | 2 +- crates/huginn-mind/src/mind.rs | 78 ++++++++++++++++++++---------- tools/eureka-cut10-mutations.psd1 | 59 +++++++++++++--------- tools/eureka-cut9-mutations.psd1 | 16 +++--- 7 files changed, 134 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b7bea5..af1e37e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,7 +306,7 @@ dependencies = [ [[package]] name = "epiphany-pipeline" version = "0.1.0" -source = "git+https://github.com/GameCult/Epiphany.git?rev=5cda0886bd937b9df18309de7f505029d0e41f4d#5cda0886bd937b9df18309de7f505029d0e41f4d" +source = "git+https://github.com/GameCult/Epiphany.git?rev=9d3a3efd56b34893f8c124d3eeb2526326bac646#9d3a3efd56b34893f8c124d3eeb2526326bac646" dependencies = [ "anyhow", "chrono", diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 9892376..d7db38a 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -58,20 +58,16 @@ impl> Daemon { /// One request, one answer. `Admit` goes to the mind whole, because the /// batch declares its own instance and admission's A1 is the check. Every - /// other request names an instance: the read path asks the leaf whether - /// that name is even grammatical before asking the mind whether it is its - /// own, so a name outside the grammar (a fullwidth fold, say) is refused - /// by name rather than read as merely a foreign mind. + /// other request names an instance: `require_instance` now applies the + /// leaf's own grammar before it compares identity, so a name outside the + /// grammar (a fullwidth fold, say) is refused by name rather than read as + /// merely a foreign mind, and the daemon asks nothing twice. pub fn handle(&mut self, request: HuginnMindRequest, now: DateTime) -> HuginnMindResponse { if let Some(declared) = request.instance() && !matches!(request, HuginnMindRequest::Admit(_)) + && let Err(refusal) = self.mind.require_instance(declared) { - if let Err(refusal) = huginn_mind::require_grammatical_instance(declared) { - return HuginnMindResponse::Refused(refusal); - } - if let Err(refusal) = self.mind.require_instance(declared) { - return HuginnMindResponse::Refused(refusal); - } + return HuginnMindResponse::Refused(refusal); } match request { HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status()), @@ -506,7 +502,7 @@ pub(crate) mod tests { assert!(!fullwidth.is_ascii(), "outside the grammar: a Slug is dot-joined ASCII labels"); let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { - field: "instance.instance".into(), + field: "declared".into(), value: fullwidth.into(), }); @@ -527,6 +523,36 @@ pub(crate) mod tests { assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(expected)); } + /// `Admit` is excluded from the daemon's own pre-check (its batch is not a + /// read naming an instance the mind is asked about first), but it reaches + /// the identical `require_instance` from inside `admit_steps`'s A1, so a + /// fullwidth *declared batch instance* is `InvalidFormat` there too, + /// named and valued the same as the read path's. One door, + /// `Mind::require_instance`, decides both. The batch's one document names + /// the mind's own real instance, `INSTANCE`, so it clears `admit`'s own + /// per-document `Bounded` check and the only fullwidth text anywhere in + /// the request is the batch's declared instance, the thing this test + /// pins. + #[test] + fn a_fullwidth_instance_is_refused_the_same_way_by_admit_and_by_query() { + let (_root, mut daemon) = seeded(); + let fullwidth = "\u{FF59}ggdrasil"; + + let expected = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "declared".into(), + value: fullwidth.into(), + }); + + let admit = HuginnMindRequest::Admit(batch(fullwidth, vec![identity(INSTANCE)])); + assert_eq!( + daemon.handle(admit, now()), + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(expected.clone())) + ); + + let query = HuginnMindRequest::Query { instance: slug(fullwidth), query: PipelineQuery::default() }; + assert_eq!(daemon.handle(query, now()), HuginnMindResponse::Refused(expected)); + } + /// A refusal a read method raised is the answer the dispatch returns, whole. /// The instance check refuses before the method runs and is pinned above; /// these are the refusals the methods themselves raise, so nothing here is diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 6074ad0..7f752b2 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -14,7 +14,7 @@ publish.workspace = true anyhow = "1" chrono = "0.4.44" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" } -epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "5cda0886bd937b9df18309de7f505029d0e41f4d", package = "epiphany-pipeline" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "9d3a3efd56b34893f8c124d3eeb2526326bac646", package = "epiphany-pipeline" } rmp-serde = "1" schemars = "1" serde = { version = "1", features = ["derive"] } diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 7e47924..55d92c6 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -36,7 +36,7 @@ pub mod wire; pub(crate) mod fixtures; pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome}; -pub use mind::{HuginnMindEpoch, Mind, require_grammatical_instance}; +pub use mind::{HuginnMindEpoch, Mind}; pub use query::{ AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage, PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery, diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index 0406a25..76f357d 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -12,10 +12,7 @@ use std::path::{Path, PathBuf}; use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore}; -use epiphany_pipeline::{ - Date, PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineInstance, PipelineKind, Short, Slug, - register_pipeline_document_types, -}; +use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types}; use crate::receipt::HuginnCommitReceipt; use crate::refusal::MindRefusal; @@ -49,29 +46,28 @@ pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal { MindRefusal::Unavailable { detail: format!("{error:#}") } } -/// A declared instance's grammar, checked before its bytes are compared to -/// anything. `Slug` is `epiphany_pipeline`'s, dot-joined ASCII labels, and the -/// leaf validates one only as a document field: its grammar check is a -/// crate-private trait, so a bare `Slug` cannot be asked directly and the only -/// public door is a real document. This wraps the declared name in a -/// throwaway `PipelineInstance` and reads the answer back through the leaf's -/// own public `PipelineDocument::validate`, so the grammar is the leaf's, -/// never re-derived here: a fold that widens what counts as ASCII (a -/// fullwidth character folded to its plain form, say) has nothing local to -/// weaken, because nothing local decides the grammar. +/// A declared name's grammar, checked before its bytes are compared to +/// anything or reach the filesystem. `Slug` is `epiphany_pipeline`'s, +/// dot-joined ASCII labels, and the leaf now opens a public door onto its own +/// check, `Slug::validate_slug`, on the pattern of `PipelineRef::validate_ref` +/// (Self's ruling on the F6 fork): the grammar is the leaf's, never +/// re-derived here, so a fold that widens what counts as ASCII (a fullwidth +/// character folded to its plain form, say) has nothing local to weaken. /// -/// Read-path callers ask this before `Mind::require_instance`, so a name -/// outside the grammar is refused by its own name — the leaf's -/// `InvalidFormat` — rather than reaching the identity comparison and being -/// read as merely a foreign mind. -pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { - let probe = PipelineDocument::Instance(PipelineInstance { - instance: declared.clone(), - display_name: Short("grammar-probe".into()), - created_at: Date("2026-01-01".into()), - host: Short("grammar-probe".into()), - }); - probe.validate().map_err(MindRefusal::Document) +/// The leaf's own refusal names its own field, `slug`, and for a dotted name +/// the one label that failed rather than the whole declared name; a caller +/// here reports neither, since both would misname what the caller actually +/// sent. This instead names the field the declared name was held under and +/// the whole declared name, so `require_instance` and `Mind::open` each +/// refuse by the name a client can recognise, not `instance.instance` (the +/// old wrapper's field, a client never sent) and not a label fragment. +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + declared.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), + }) + }) } /// A cache that knows the fifteen types a mind's store may hold: the leaf's @@ -114,6 +110,7 @@ impl Mind { /// path, in this process or another, is `MindAlreadyOwned`. An empty /// store is a valid open; only the first admission may write into it. pub fn open(state_root: &Path, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; let path = Self::path_for(state_root, instance); let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| { // The store reports a held lock as an error like any other; its @@ -135,6 +132,7 @@ impl Mind { /// one `instance` document naming the declared instance; then register, /// attach and pull. pub fn open_with(store: S, instance: &Slug) -> Result { + require_grammatical_slug("instance", instance)?; let raw = store.pull_all().map_err(unavailable)?; refuse_foreign_types(&raw)?; refuse_foreign_epoch(&raw)?; @@ -152,6 +150,7 @@ impl Mind { /// asks it for every read that names an instance, and compares nothing /// itself. pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal> { + require_grammatical_slug("declared", declared)?; if declared != self.instance() { return Err(MindRefusal::ForeignInstance { declared: declared.0.clone(), @@ -508,4 +507,31 @@ mod tests { assert!(second.is_empty()); assert_eq!(Mind::path_for(root.path(), &yggdrasil), root.path().join("minds").join(INSTANCE).join("mind.redb")); } + + /// N1, Soul's probe: `Mind::open(outer/inner, Slug("..\..\escaped"))` used + /// to return `Ok` and create `outer/escaped/mind.redb`, a store outside + /// the state root nothing beneath `outer/inner` names. `path_for` joins a + /// declared name as written and never validated it, so a `Slug` carrying + /// `..` and a path separator escaped through the one door that touches the + /// filesystem. `require_grammatical_slug` now runs before `path_for` is + /// even called, so the store is never created and the refusal is the + /// leaf's own grammar, not a filesystem error surfacing later. + #[test] + fn a_slug_outside_the_grammar_cannot_escape_the_state_root() { + let outer = tempfile::tempdir().unwrap(); + let inner = outer.path().join("inner"); + std::fs::create_dir_all(&inner).unwrap(); + + let escaping = Slug("..\\..\\escaped".into()); + assert_eq!( + Mind::open(&inner, &escaping).err(), + Some(MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "instance".into(), + value: escaping.0.clone(), + })) + ); + + assert!(!outer.path().join("escaped").join("mind.redb").exists(), "no store escaped the state root"); + assert!(!inner.join("minds").exists(), "no store was created under the state root either"); + } } diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index 21e1500..2fb4f82 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -163,38 +163,38 @@ } @{ Id = 'S3wide' - Rule = 'The grammar check runs on the declared bytes as given: nothing folds a fullwidth character to its plain ASCII form before asking the leaf whether the name is grammatical.' + Rule = 'The grammar check runs on the declared bytes as given: nothing folds a fullwidth character to its plain ASCII form before asking the leaf''s Slug::validate_slug whether the name is grammatical.' Test = 'daemon::tests::a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check' Command = 'cargo test -p huginn-daemon --lib' File = 'crates/huginn-mind/src/mind.rs' Old = @' -pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { - let probe = PipelineDocument::Instance(PipelineInstance { - instance: declared.clone(), - display_name: Short("grammar-probe".into()), - created_at: Date("2026-01-01".into()), - host: Short("grammar-probe".into()), - }); - probe.validate().map_err(MindRefusal::Document) +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + declared.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), + }) + }) } '@ New = @' -pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> { - let folded: String = declared - .0 - .chars() - .map(|c| match c as u32 { - 0xFF01..=0xFF5E => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c), - _ => c, +fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefusal> { + let folded = Slug( + declared + .0 + .chars() + .map(|c| match c as u32 { + 0xFF01..=0xFF5E => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c), + _ => c, + }) + .collect(), + ); + folded.validate_slug().map_err(|_| { + MindRefusal::Document(epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: field.into(), + value: declared.0.clone(), }) - .collect(); - let probe = PipelineDocument::Instance(PipelineInstance { - instance: Slug(folded), - display_name: Short("grammar-probe".into()), - created_at: Date("2026-01-01".into()), - host: Short("grammar-probe".into()), - }); - probe.validate().map_err(MindRefusal::Document) + }) } '@ } @@ -577,6 +577,17 @@ pub fn require_grammatical_instance(declared: &Slug) -> Result<(), MindRefusal> }), '@ } + # S1 and S1d's fixture, `open_items_refuses_on_a_fault_inside_the_requested_campaign`, + # runs three faults in one test body: an orphan receipt, an undecodable + # document, and a document two receipts claim. N5, correcting an + # earlier claim here: neither mutant "dies on all three". S1d's own + # `New` exempts any detail containing "has no commit receipt" by + # construction, so the orphan block never fails under it; the test + # panics at the very next block, the undecodable document, and never + # reaches the third. S1 has no such exemption and is killed by the + # same first block its own widened check reaches, the orphan. Each + # mutant dies on the fault its own body actually mangles, not on all + # three the fixture happens to carry. @{ Id = 'S1' Rule = 'open_items refuses on a fault anywhere in the image it must read, not only on one outside the requested campaign.' diff --git a/tools/eureka-cut9-mutations.psd1 b/tools/eureka-cut9-mutations.psd1 index 7447991..ddb4194 100644 --- a/tools/eureka-cut9-mutations.psd1 +++ b/tools/eureka-cut9-mutations.psd1 @@ -1,4 +1,4 @@ -# Cut 9 mutations, V1-V23: the read side's rules, each entry restoring one +# Cut 9 mutations, V1-V24L: the read side's rules, each entry restoring one # permissiveness the cut closed and naming the test that must fail while it is # applied. A rule with two weakenings has two entries: `Vn` is the revert, the # rule simply absent, and `VnL` is the loosening, the rule weakened rather than @@ -16,14 +16,12 @@ # `eureka-cut8-mutations.psd1` -- one mutant, two suites, one owner. # # Two weakenings the spec names have no entry, not because they are -# unreachable but because nothing on this Body's own public surface reaches -# them today, which is a narrower claim than the one this header used to make. -# "No behaviour this Body can reach" rested on admission being the only write -# path, and that premise is false: `MindStore::compare_and_swap_batch` is -# `pub`, and `store.rs`'s own re-export of `CacheBackingStore` already lets -# code outside this crate push a row that skips A5 (and every other admission -# rule) entirely, the same door the cut9 header's line 18-24 claim rested on. -# Recorded as not yet reached, not as unreachable: +# unreachable but because nothing in this suite exercises the path that would +# reach them. `MindStore::compare_and_swap_batch` is `pub`, and `store.rs`'s +# own re-export of `CacheBackingStore` already lets code outside this crate +# push a row that skips A5 (and every other admission rule) entirely -- the +# door named at `store.rs:18-19`. Recorded as not yet reached, not as +# unreachable: # # - `assignments_of` comparing the repo and ignoring the instance. A5 refuses # a stewardship naming another instance for every row admission writes, so