diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt new file mode 100644 index 0000000..e69de29 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index fbf5c83..a3c4b22 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -1,3229 +1,3335 @@ -//! Eureka pipeline documents: the typed shape of a Eureka campaign's state. -//! -//! This is a leaf type library, and deliberately nothing else. It owns document -//! shape, field bounds, formats, key derivation and the JSON schemas published -//! under `schemas/cultnet`; it owns no storage, no admission, no process and no -//! network. That is the whole reason it is a package: the organ that will admit -//! these documents depends on these types without depending on the harness -//! that used to hold them. -//! -//! Every kind is a plain `serde` + `JsonSchema` value inside a one-slot -//! `DatabaseEntry` wrapper, always prepared with `prepare_entry_named`, so the -//! stored payload is `[value]` and the value is the named map the published -//! schema describes. Keys are semantic: `pipeline_key` derives them from the -//! value, and the write validator refuses any envelope whose key differs. -//! -//! Every key is `::`, three segments for every kind with -//! none excused: the root is a `Slug`, the kind is the literal kind name, and -//! the local is `Label`s joined by `.`, so no local part carries a dot and a -//! reader recovers the parts by splitting. A root's local is the constant -//! `self`; a resolution's local is its subject's kind and local. A -//! resolution's local ends in its per-subject sequence, `n`, and a -//! stewardship's in its per-repo sequence, so a subject's resolutions and a -//! repo's assignments each share a prefix. -//! -//! **Validation follows the type.** `value_types!` is the single field list: it -//! emits the struct and its `Bounded` impl from the same tokens, so a field -//! cannot be declared without being validated. Format rules live in the field's -//! type (`Label`, `Slug`, `OrgRepo`, `Sha`, `Sha256Hex`, `Date`), never in a -//! hand-written impl that a later field can slip past. A `Vec` field must carry -//! a maximum, because `Vec` has no `Bounded` impl of its own. -//! -//! The wrappers are crate-private: outside code registers, prepares and -//! decodes them through `register_pipeline_document_types`, -//! `PipelineDocument::prepare` and `PipelineDocument::decode`, and validates a -//! write through `validate_pipeline_write_envelope`. - -// `anyhow` is the cache's own error type, which `prepare` and the registrar -// pass through; every refusal this crate decides is a `PipelineRefusal`. -use anyhow::Result; -use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// Typed refusals of the pipeline documents: bounds, formats and key identity -/// (D2's document half), and `ForeignStore`, raised by `decode` for an -/// envelope of any other type. This is every refusal the leaf decides; a -/// store's identity and its schema epoch are not decided here, since this -/// crate owns no store, and belong to the organ that will admit these -/// documents. -/// -/// Serialised externally tagged, serde's default for an enum: each variant is -/// a one-key map from the variant's name to its named fields, so `field` and -/// `value` are on the wire under their own names and a reader of a refusal -/// carried over a wire reads the same parts a local caller matches on. No -/// attribute buys that, which is why there is none: an internal or adjacent -/// tag would move the fields under a content key without making any of them -/// more visible. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub enum PipelineRefusal { - FieldBound { field: String, limit: u32, actual: u32 }, - InvalidFormat { field: String, value: String }, - InvalidIdentity { kind: PipelineKind, key: String, expected: String }, - DuplicateRepo { field: String, repo: String }, - ForeignStore { r#type: String }, -} - -impl std::fmt::Display for PipelineRefusal { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "pipeline refusal: {self:?}") - } -} - -impl std::error::Error for PipelineRefusal {} - -pub(crate) trait Bounded { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal>; -} - -impl Bounded for Option { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - self.as_ref().map_or(Ok(()), |value| value.validate(field)) - } -} - -macro_rules! unbounded_scalars { - ($($ty:ty),* $(,)?) => {$( - impl Bounded for $ty { - fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> { - Ok(()) - } - } - )*}; -} - -unbounded_scalars!(u32, u64, bool); - -fn bound(field: &str, limit: usize, actual: usize) -> Result<(), PipelineRefusal> { - if actual > limit { - return Err(PipelineRefusal::FieldBound { - field: field.into(), - limit: limit as u32, - actual: actual as u32, - }); - } - Ok(()) -} - -/// List maximums only. Minimum counts are admission rules with their own -/// refusals (Cut 3b), so a bounds pass never pre-empts them. -fn list(field: &str, items: &[T], max: usize) -> Result<(), PipelineRefusal> { - bound(field, max, items.len())?; - for (index, item) in items.iter().enumerate() { - item.validate(&format!("{field}[{index}]"))?; - } - Ok(()) -} - -fn format_error(field: &str, value: &str) -> PipelineRefusal { - PipelineRefusal::InvalidFormat { - field: field.into(), - value: value.into(), - } -} - -fn hex(field: &str, value: &str, lengths: std::ops::RangeInclusive) -> Result<(), PipelineRefusal> { - let lower_hex = value.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')); - if !lengths.contains(&value.len()) || !lower_hex { - return Err(format_error(field, value)); - } - Ok(()) -} - -/// A key label: `[A-Za-z0-9_-]{1,64}`. Dots are excluded so that a composed key -/// segments unambiguously; see `local`. -fn label_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { - let valid = value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); - if !valid || value.is_empty() || value.len() > 64 { - return Err(format_error(field, value)); - } - Ok(()) -} - -/// A dotted key segment: label parts joined by `.`, at most 64 bytes. Every -/// part must be a label, so `..`, a leading or trailing dot, and an empty part -/// are all refused. -fn dotted_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { - dotted_within(field, value, 64) -} - -/// `dotted_text`'s grammar with the bound as a parameter: the one place a -/// dotted segment is parsed, whoever owns the bound. -fn dotted_within(field: &str, value: &str, max: usize) -> Result<(), PipelineRefusal> { - if value.is_empty() || value.len() > max { - return Err(format_error(field, value)); - } - for part in value.split('.') { - label_text(field, part)?; - } - Ok(()) -} - -/// GitHub's own grammar for `owner/repo`, exactly one `/`: the owner is -/// `[A-Za-z0-9-]`, 1 to 39 bytes, with no leading or trailing hyphen; the repo -/// is `[A-Za-z0-9._-]`, 1 to 100 bytes, is never `.` or `..`, and does not end -/// in `.git`, ASCII-case-insensitively (`a/b.GIT` is refused exactly as -/// `a/b.git` is), so the suffix rule cannot be dodged by a case variant whose -/// `identity()` is refused. The repo's own byte alphabet excludes `/`, so a -/// second `/` inside it is already refused by the byte check; no separate -/// `contains('/')` check is needed. -fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { - let valid = value.split_once('/').is_some_and(|(owner, repo)| { - (1..=39).contains(&owner.len()) - && owner.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - && !owner.starts_with('-') - && !owner.ends_with('-') - && (1..=100).contains(&repo.len()) - && repo.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - && repo != "." - && repo != ".." - && !(repo.len() >= 4 && repo[repo.len() - 4..].eq_ignore_ascii_case(".git")) - }); - if valid { - Ok(()) - } else { - Err(format_error(field, value)) - } -} - -macro_rules! bounded_text { - ($($(#[$doc:meta])* $name:ident = $limit:literal, $check:expr;)*) => {$( - #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] - #[serde(transparent)] - #[schemars(extend("maxLength" = $limit))] - $(#[$doc])* - pub struct $name(pub String); - impl Bounded for $name { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - let check: fn(&str, &str) -> Result<(), PipelineRefusal> = $check; - check(field, &self.0) - } - } - impl From<&str> for $name { - fn from(value: &str) -> Self { - Self(value.into()) - } - } - )*}; -} - -fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> { - move |field: &str, value: &str| bound(field, limit, value.len()) -} - -/// A title holds at least one `char::is_alphanumeric()` character, no -/// `char::is_control()` character, no U+2028 LINE SEPARATOR or U+2029 -/// PARAGRAPH SEPARATOR (both `Zl`/`Zp`, so `is_control()` does not reach -/// them, and a one-line field should carry no hard line break), and no bidi -/// control character: `U+061C`, `U+200E`, `U+200F`, `U+202A`-`U+202E`, -/// `U+2066`-`U+2069`. No denylist of individual zero-width or format -/// characters is maintained here; a character not covered by one of these -/// tests is allowed, so ZWJ and ZWNJ still work. -fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { - let alphanumeric = value.chars().any(|character| character.is_alphanumeric()); - let control = value - .chars() - .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')); - let bidi = value.chars().any(|character| { - matches!( - character, - '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' - ) - }); - if !alphanumeric || control || bidi { - return Err(format_error(field, value)); - } - within(200)(field, value) -} - -bounded_text! { - /// Text of at most 200 UTF-8 bytes. - Short = 200, |field, value| within(200)(field, value); - /// A title: 1 to 200 UTF-8 bytes, enforced by admission; JSON Schema - /// length counts characters. Holds at least one alphanumeric character, - /// no control character, and no bidi control character (`U+061C`, - /// `U+200E`, `U+200F`, `U+202A`-`U+202E`, `U+2066`-`U+2069`). - #[schemars(extend("minLength" = 1))] - Title = 200, title_text; - /// Text of at most 1,000 UTF-8 bytes. - Line = 1000, |field, value| within(1000)(field, value); - /// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`. - Para = 4000, |field, value| within(4000)(field, value); - /// A key label: `[A-Za-z0-9_-]{1,64}`. - Label = 64, label_text; - /// A campaign slug or key segment: label parts joined by `.`. - Slug = 64, dotted_text; - /// A git commit id: 7-40 lowercase hex characters. - Sha = 40, |field, value| hex(field, value, 7..=40); - /// A full git commit id: exactly 40 lowercase hex characters. - FullSha = 40, |field, value| hex(field, value, 40..=40); - /// A SHA-256 digest: exactly 64 lowercase hex characters. - Sha256Hex = 64, |field, value| hex(field, value, 64..=64); -} - -impl Sha { - /// The type's identity rule: two spellings name one commit when one is a - /// prefix of the other, so a 7-character abbreviation and the full id of - /// the same commit are equal here though not `==`. Both are already 7-40 - /// lowercase hex. Two distinct commits that share a 7-character prefix are - /// git's ambiguity, not the mind's. - pub fn names_same_commit(&self, other: &Sha) -> bool { - self.0.starts_with(&other.0) || other.0.starts_with(&self.0) - } -} - -impl Slug { - /// The public door onto the slug grammar, for a caller outside this crate - /// that holds a bare `Slug` and no way to reach `Bounded`, which is - /// crate-private. On the pattern of `PipelineRef::validate_ref`: it - /// delegates so the grammar keeps one owner, and it applies exactly the - /// `dotted_text` check that every `Slug` field in this crate is held to, - /// so a caller that validates a declared name this way and a document - /// field of the same type refuse the same inputs. Named `validate_slug` - /// rather than a bare `validate`, because an inherent `validate` would - /// shadow `Bounded`'s for every in-crate caller holding a `Slug`. - pub fn validate_slug(&self) -> Result<(), PipelineRefusal> { - Bounded::validate(self, "slug") - } -} - -/// A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 + -/// 100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or -/// trailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never -/// `.` or `..`, and does not end in `.git`, ASCII-case-insensitively. -/// Identity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany` -/// are the same repository. -// -// The raw string is display-only. `OrgRepo` is not generated by -// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd` -// and `Ord` on the raw string, with no per-member override, so a type built -// from it compares and keys on spelling. `OrgRepo` instead carries -// hand-written impls of those five traits, every one of them over -// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a -// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany` -// and `gamecult/epiphany` compare equal, hash equal, order together and -// collapse to one map entry, and a caller cannot reach the case-sensitive -// behaviour by accident. `Hash` and `Eq` agree because both are defined over -// the same `identity()` string. Every other bounded type keeps the macro's -// derives untouched. This reasoning is a source comment, not a doc comment, -// so it does not enter the published schema description (RS-L closing fix 2): -// a consumer needs the grammar, the bound and the case-insensitivity, not the -// Rust-macro justification for how that got built. -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] -#[serde(transparent)] -#[schemars(extend("maxLength" = 140))] -pub struct OrgRepo(pub String); - -impl Bounded for OrgRepo { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - org_repo_text(field, &self.0) - } -} - -impl From<&str> for OrgRepo { - fn from(value: &str) -> Self { - Self(value.into()) - } -} - -impl PartialEq for OrgRepo { - fn eq(&self, other: &Self) -> bool { - self.identity() == other.identity() - } -} - -impl Eq for OrgRepo {} - -impl std::hash::Hash for OrgRepo { - fn hash(&self, state: &mut H) { - self.identity().hash(state); - } -} - -impl PartialOrd for OrgRepo { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for OrgRepo { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.identity().cmp(&other.identity()) - } -} - -impl OrgRepo { - /// The public door onto the `org_repo` grammar, for a caller outside this - /// crate that holds a bare `OrgRepo` (the organ's `repo` alias, RS-3) and - /// no way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it - /// delegates to `org_repo_text` through `Bounded::validate`, so the - /// grammar keeps one owner and a declared name checked this way refuses - /// exactly what a document's own `OrgRepo` field refuses. - pub fn validate_org_repo(&self) -> Result<(), PipelineRefusal> { - Bounded::validate(self, "org_repo") - } - - /// The one canonical key for an `OrgRepo`: the value, ASCII-lowercased. - /// Every comparison, hash, ordering and lookup keyed by repository - /// identity goes through this — `PartialEq`, `Hash` and `Ord` above are - /// all defined in terms of it — so `GameCult/Epiphany` and - /// `gamecult/epiphany` are the same repository everywhere, not only where - /// a caller remembers to ask. - pub fn identity(&self) -> String { - self.0.to_ascii_lowercase() - } -} - -impl Label { - /// The public door onto the label grammar, for a caller outside this - /// crate that holds a bare `Label` (the organ's `cut` alias, RS-3) and no - /// way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it - /// delegates to `label_text` through `Bounded::validate`, so the grammar - /// keeps one owner and a declared name checked this way refuses exactly - /// what a document's own `Label` field refuses. - pub fn validate_label(&self) -> Result<(), PipelineRefusal> { - Bounded::validate(self, "label") - } -} - -/// A calendar date, `YYYY-MM-DD`. -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] -#[serde(transparent)] -#[schemars(extend("maxLength" = 10))] -pub struct Date(pub String); - -impl Bounded for Date { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - match chrono::NaiveDate::parse_from_str(&self.0, "%Y-%m-%d") { - Ok(_) if self.0.len() == 10 => Ok(()), - _ => Err(format_error(field, &self.0)), - } - } -} - -macro_rules! bounded_field { - ($value:expr, $field:expr) => { - Bounded::validate(&$value, $field)? - }; - ($value:expr, $field:expr, $max:literal) => { - list($field, &$value, $max)? - }; -} - -/// The single field list: one invocation emits the value struct and its -/// `Bounded` impl, so a field can never be left out of validation. -macro_rules! value_types { - ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$( - $(#[$attr])* - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct $name { - $( - $(#[schemars(extend("maxItems" = $max))])? - pub $field: $ty - ),* - } - - impl Bounded for $name { - fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { - $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* - $($extra(self, at)?;)? - Ok(()) - } - } - )*}; -} - -macro_rules! unit_enums { - ($($name:ident { $($variant:ident),* $(,)? })*) => {$( - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] - pub enum $name { $($variant),* } - impl Bounded for $name { - fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> { - Ok(()) - } - } - )*}; -} - -/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same -/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and -/// `gamecult/epiphany` in one list refuse exactly as one entry twice would. -/// This is a cross-element invariant on `PipelineCampaign` alone, not a -/// per-type format rule any other `value_types!` struct shares, so it is -/// wired in as that struct's one `=> extra` hook rather than folded into -/// `Bounded for OrgRepo` or the list bound in `bounded_field!`. -fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { - let mut seen = std::collections::HashSet::new(); - for repo in &campaign.repos { - if !seen.insert(repo.identity()) { - return Err(PipelineRefusal::DuplicateRepo { - field: format!("{at}.repos"), - repo: repo.0.clone(), - }); - } - } - Ok(()) -} - -unit_enums! { - EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } - ClaimOutcome { Holds, Falsified, Unproven } - FindingConfidence { Confirmed, Plausible } - FindingSeverity { Blocker, High, Medium, Low } - RulingAuthority { Operator, Standing, Defaulted } - FindingOrigin { Introduced, PreExisting } -} - -value_types! { - pub struct CodeLocation { path: Short, line: u32, end_line: Option } - pub struct CommitRange { base: Sha, head: Sha } - pub struct Evidence { kind: EvidenceKind, locator: Line, result: Line } - /// A citation of a document in another repo's pipeline store (D6). - pub struct ForeignRef { repo: OrgRepo, commit: FullSha, kind: PipelineKind, id: Short, payload_sha256: Sha256Hex } - pub struct DocRef { path: Short, start_line: u32, end_line: u32, commit: Sha } - pub struct TargetInvariant { label: Label, statement: Line } - pub struct QuestionOption { label: Label, text: Line } - pub struct CutDelete { path: Short, lines: u32, note: Line } - pub struct FileChange { location: CodeLocation, change: Line } - pub struct AuthorityMap { - owner: Line, inputs: Vec[16], outputs: Vec[16], derived_state: Vec[16], - forbidden_writers: Vec[16], shared_paths: Vec[16], deletion_line: Line, - } - pub struct VerificationTest { name: Short, pins: Line } - pub struct NegativeCheck { pattern: Short, scope: Line } - pub struct CutVerification { - builds: Vec[64], tests: Vec[64], - negative: Vec[64], operator: Vec[64], - } - pub struct ReportCommit { sha: Sha, subject: Line, builds: bool } - /// One mutation a report ran: a key-safe label a verdict claim can name, - /// the exact edit, and the tree it was applied to. - pub struct MutationRecord { label: Label, rule: Line, location: CodeLocation, before: Line, after: Line, commit: Sha, failed_as_expected: bool } - pub struct Deviation { what: Line, why: Line } - // D1 tables no maximum for these lists, so they take the shared list - // default of 64 rather than a number invented for this field alone. - pub struct StructuralDelta { - lines_added: u32, lines_removed: u32, - dependencies_added: Vec[64], dependencies_removed: Vec[64], - formats_added: Vec[64], formats_removed: Vec[64], - targets_added: Vec[64], targets_removed: Vec[64], - } - /// A name the cut landed, and where it lives. - pub struct LandedName { name: Short, path: Short } - /// A promise a report makes about what it landed. Not a document and not - /// resolvable: it is identified by its report's id and its label, as a - /// `TargetInvariant` is by its target's. Soul measures every one (ruling - /// A), and the rule that every one is measured is admission's. - pub struct Promise { label: Label, text: Line } - /// The promise this claim measured, and the report's mutations it ran. An - /// `Unproven` claim with a promise is one Soul could not reach. - pub struct VerdictClaim { - claim: Line, outcome: ClaimOutcome, evidence: Vec[8], findings: Vec[16], - promise: Option