Check a declared instance against the slug grammar on the read path
F6: no declared instance Slug was ever checked against Slug's own grammar before reaching Mind::require_instance's byte comparison, so a name outside the grammar read as merely a foreign mind rather than being refused by its own name. Slug's grammar check is a crate-private trait in epiphany_pipeline, reachable only through a real document, so require_grammatical_instance wraps the declared name in a throwaway PipelineInstance and reads the answer back through PipelineDocument::validate, the leaf's own public door; nothing here re-derives the grammar. Daemon::handle asks it on the read path, before Mind::require_instance, for every request that names an instance. Add a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check, driving a fullwidth Latin small letter y, and mutation entry S3wide, which folds a fullwidth character to plain ASCII before validating and dies against it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7611d1bbf1
commit
4ecbf53056
4 changed files with 112 additions and 6 deletions
|
|
@ -58,14 +58,20 @@ 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, and the mind is asked whether that is
|
||||
/// its own before the method runs.
|
||||
/// 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.
|
||||
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)
|
||||
{
|
||||
return HuginnMindResponse::Refused(refusal);
|
||||
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);
|
||||
}
|
||||
}
|
||||
match request {
|
||||
HuginnMindRequest::Whoami => HuginnMindResponse::Whoami(self.mind.status()),
|
||||
|
|
@ -484,6 +490,43 @@ pub(crate) mod tests {
|
|||
assert_eq!(status(&mut daemon).documents, 1, "nothing landed");
|
||||
}
|
||||
|
||||
/// A declared instance outside `Slug`'s grammar is refused by its own
|
||||
/// name, `InvalidFormat`, on the read path, before the identity
|
||||
/// comparison ever runs: it is not merely a foreign mind, it names no
|
||||
/// mind a client could compose. A fullwidth Latin small letter y is used
|
||||
/// because it is the shape Soul named: a check that folds it to plain
|
||||
/// ASCII `y` before validating would let this probe pass as grammatical
|
||||
/// and reach `require_instance`, which would then answer `ForeignInstance`
|
||||
/// instead — the wrong refusal, reached the wrong way.
|
||||
#[test]
|
||||
fn a_declared_instance_outside_the_grammar_is_refused_by_name_before_the_identity_check() {
|
||||
let (_root, mut daemon) = seeded();
|
||||
let fullwidth = "\u{FF59}ggdrasil";
|
||||
assert_ne!(fullwidth, INSTANCE, "not the mind's own bytes");
|
||||
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(),
|
||||
value: fullwidth.into(),
|
||||
});
|
||||
|
||||
let query = HuginnMindRequest::Query { instance: slug(fullwidth), query: PipelineQuery::default() };
|
||||
assert_eq!(daemon.handle(query, now()), HuginnMindResponse::Refused(expected.clone()));
|
||||
|
||||
let view = HuginnMindRequest::View {
|
||||
instance: slug(fullwidth),
|
||||
id: PipelineRef { kind: PipelineKind::Question, id: Short("not-reached".into()) },
|
||||
};
|
||||
assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(expected.clone()));
|
||||
|
||||
let items = HuginnMindRequest::OpenItems { instance: slug(fullwidth), campaign: slug(CAMPAIGN) };
|
||||
assert_eq!(daemon.handle(items, now()), HuginnMindResponse::Refused(expected.clone()));
|
||||
|
||||
let scope = HistoryScope::Repo(OrgRepo("GameCult/Huginn".into()));
|
||||
let history = HuginnMindRequest::History { instance: slug(fullwidth), scope };
|
||||
assert_eq!(daemon.handle(history, 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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub mod wire;
|
|||
pub(crate) mod fixtures;
|
||||
|
||||
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
|
||||
pub use mind::{HuginnMindEpoch, Mind};
|
||||
pub use mind::{HuginnMindEpoch, Mind, require_grammatical_instance};
|
||||
pub use query::{
|
||||
AdmissionFacts, HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage,
|
||||
PipelineStatus, QUERY_LIMIT_MAX, SemanticQuery,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore};
|
||||
use epiphany_pipeline::{
|
||||
PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types,
|
||||
Date, PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineInstance, PipelineKind, Short, Slug,
|
||||
register_pipeline_document_types,
|
||||
};
|
||||
|
||||
use crate::receipt::HuginnCommitReceipt;
|
||||
|
|
@ -48,6 +49,31 @@ 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.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// A cache that knows the fifteen types a mind's store may hold: the leaf's
|
||||
/// thirteen through its registrar, the epoch record and the commit receipt.
|
||||
/// It is the cache every envelope is prepared against.
|
||||
|
|
|
|||
|
|
@ -143,6 +143,43 @@
|
|||
Old = ' if declared != self.instance() {'
|
||||
New = ' if !declared.0.eq_ignore_ascii_case(&self.instance().0) {'
|
||||
}
|
||||
@{
|
||||
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.'
|
||||
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)
|
||||
}
|
||||
'@
|
||||
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,
|
||||
})
|
||||
.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)
|
||||
}
|
||||
'@
|
||||
}
|
||||
@{
|
||||
Id = 'D2'
|
||||
Rule = 'Rulings 14 and 18 on the wire: the daemon passes the declared instance to the mind as the client sent it.'
|
||||
|
|
|
|||
Loading…
Reference in a new issue