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 <noreply@anthropic.com>
This commit is contained in:
parent
7fc8ed3610
commit
cc71a569dc
7 changed files with 134 additions and 73 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -58,20 +58,16 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
|
|||
|
||||
/// 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<Utc>) -> 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
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<OwnedRedbMessagePackBackingStore> {
|
|||
/// 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<Self, MindRefusal> {
|
||||
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<S: MindStore> Mind<S> {
|
|||
/// one `instance` document naming the declared instance; then register,
|
||||
/// attach and pull.
|
||||
pub fn open_with(store: S, instance: &Slug) -> Result<Self, MindRefusal> {
|
||||
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<S: MindStore> Mind<S> {
|
|||
/// 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue