Ghostlight import key: one canonical predicate, one uniqueness function, state gate
The record key is canonical free text per ruling import-key-canonical-text (NFC, no Cc/Cf, only U+0020 whitespace, no edge or doubled space). The resolver, admit_resolved and verify_state_shape all call the same predicate and import_key_fault; admit_resolved's second held-set iteration is deleted. Adds unicode-normalization and unicode-properties. Not yet verified on Yggdrasil; build status of this commit unproven. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c750e21c2d
commit
1afd942be3
5 changed files with 118 additions and 31 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -676,6 +676,8 @@ dependencies = [
|
|||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -2137,6 +2139,21 @@ version = "1.0.24"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ sha2.workspace = true
|
|||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-properties = "0.1.4"
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -826,6 +826,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError>
|
|||
"one subject holds two overlapping jurisdictions of one kind".into(),
|
||||
));
|
||||
}
|
||||
let mut declared_keys = BTreeSet::new();
|
||||
let no_held = BTreeSet::new();
|
||||
for subject in state.subjects.values() {
|
||||
if let Some(key) = &subject.import_key
|
||||
&& super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some()
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"subject import key is malformed or held by two subjects".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut controller_ids = BTreeSet::new();
|
||||
for (subject_id, subject) in &state.subjects {
|
||||
if !super::patch::is_canonical_text(&subject.label) {
|
||||
|
|
|
|||
|
|
@ -2132,6 +2132,21 @@ fn admit_resolved(
|
|||
));
|
||||
}
|
||||
}
|
||||
// The subjects map's only writer holds the same injectivity the resolver
|
||||
// checks, decided by the same function.
|
||||
{
|
||||
let held = patch::held_import_keys(state.subjects.values());
|
||||
let mut declared = BTreeSet::new();
|
||||
for subject in &resolved.subjects {
|
||||
if let Some(key) = &subject.subject.import_key
|
||||
&& patch::import_key_fault(key, &held, &mut declared).is_some()
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted subject's import key is malformed, held or repeated".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for subject in &resolved.subjects {
|
||||
let scope = DecisionScope {
|
||||
subject_id: subject.subject_id,
|
||||
|
|
@ -2177,19 +2192,6 @@ fn admit_resolved(
|
|||
"admitted controller ID collision".into(),
|
||||
));
|
||||
}
|
||||
// The subjects map's only writer holds the same injectivity the
|
||||
// resolver checks, so no resolved effect can install a second
|
||||
// subject for one record.
|
||||
if subject.subject.import_key.is_some()
|
||||
&& state
|
||||
.subjects
|
||||
.values()
|
||||
.any(|held| held.import_key == subject.subject.import_key)
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted subject repeats a held import key".into(),
|
||||
));
|
||||
}
|
||||
if state
|
||||
.subjects
|
||||
.insert(subject.subject_id, subject.subject.clone())
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
|
||||
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
|
@ -975,12 +976,69 @@ impl ImportKey {
|
|||
&self.record
|
||||
}
|
||||
|
||||
fn is_canonical(&self) -> bool {
|
||||
[&self.schema, &self.record].into_iter().all(|part| {
|
||||
!part.is_empty()
|
||||
&& part.len() <= MAX_IMPORT_KEY_PART_BYTES
|
||||
&& !part.chars().any(char::is_whitespace)
|
||||
/// The one predicate for an import key, called by the resolver and by the
|
||||
/// load-time state gate alike. The record part is canonical free text: not
|
||||
/// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC
|
||||
/// (refused, never normalised, so the kernel derives no second spelling),
|
||||
/// no control or format character, and no whitespace but U+0020. The schema
|
||||
/// part is a schema id: the same text with no space at all.
|
||||
pub(super) fn is_canonical(&self) -> bool {
|
||||
is_canonical_key_text(&self.record)
|
||||
&& is_canonical_key_text(&self.schema)
|
||||
&& !self.schema.contains(' ')
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_key_text(part: &str) -> bool {
|
||||
!part.is_empty()
|
||||
&& part.len() <= MAX_IMPORT_KEY_PART_BYTES
|
||||
&& !part.starts_with(' ')
|
||||
&& !part.ends_with(' ')
|
||||
&& !part.contains(" ")
|
||||
&& unicode_normalization::is_nfc(part)
|
||||
&& part.chars().all(|c| {
|
||||
!c.is_control()
|
||||
&& c.general_category() != GeneralCategory::Format
|
||||
&& (c == ' ' || !c.is_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
/// Why an import key cannot join a world: the resolver names these by draft
|
||||
/// handle, the state gates by invariant text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ImportKeyFault {
|
||||
Malformed,
|
||||
Held,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// The keys the subjects hold, retired ones included, so a key is never
|
||||
/// reissued. Derived on demand; no stored index.
|
||||
pub(super) fn held_import_keys<'a>(
|
||||
subjects: impl IntoIterator<Item = &'a super::SubjectState>,
|
||||
) -> BTreeSet<&'a ImportKey> {
|
||||
subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The one decision on a key: malformed, held by a subject already in the
|
||||
/// world, or already claimed in `declared` (which it joins). Every door that
|
||||
/// admits or loads subjects calls this, so none can decide otherwise.
|
||||
pub(super) fn import_key_fault<'a>(
|
||||
key: &'a ImportKey,
|
||||
held: &BTreeSet<&ImportKey>,
|
||||
declared: &mut BTreeSet<&'a ImportKey>,
|
||||
) -> Option<ImportKeyFault> {
|
||||
if !key.is_canonical() {
|
||||
Some(ImportKeyFault::Malformed)
|
||||
} else if held.contains(key) {
|
||||
Some(ImportKeyFault::Held)
|
||||
} else if !declared.insert(key) {
|
||||
Some(ImportKeyFault::Duplicate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3192,13 +3250,7 @@ pub(super) fn resolve_patch(
|
|||
kind_names.insert(kernel_speak_entry().kind);
|
||||
}
|
||||
|
||||
// Derived from the subjects the world holds, retired ones included, so a
|
||||
// key is never reissued; no stored index.
|
||||
let held_keys: BTreeSet<&ImportKey> = state
|
||||
.subjects
|
||||
.values()
|
||||
.filter_map(|subject| subject.import_key.as_ref())
|
||||
.collect();
|
||||
let held_keys = held_import_keys(state.subjects.values());
|
||||
let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new();
|
||||
for (position, declaration) in patch.declarations.iter().enumerate() {
|
||||
let (handle, label, kind) = match declaration {
|
||||
|
|
@ -3243,12 +3295,15 @@ pub(super) fn resolve_patch(
|
|||
&& let Some(key) = &subject.import_key
|
||||
{
|
||||
let handle = subject.handle.clone();
|
||||
if !key.is_canonical() {
|
||||
mismatches.push(Mismatch::MalformedImportKey { handle });
|
||||
} else if held_keys.contains(key) {
|
||||
mismatches.push(Mismatch::ImportKeyHeld { handle });
|
||||
} else if !declared_keys.insert(key) {
|
||||
mismatches.push(Mismatch::DuplicateImportKey { handle });
|
||||
match import_key_fault(key, &held_keys, &mut declared_keys) {
|
||||
Some(ImportKeyFault::Malformed) => {
|
||||
mismatches.push(Mismatch::MalformedImportKey { handle });
|
||||
}
|
||||
Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }),
|
||||
Some(ImportKeyFault::Duplicate) => {
|
||||
mismatches.push(Mismatch::DuplicateImportKey { handle });
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Declaration::Affordance(affordance) = declaration {
|
||||
|
|
|
|||
Loading…
Reference in a new issue