diff --git a/epiphany-core/src/pipeline_documents.rs b/epiphany-core/src/pipeline_documents.rs index 3ff6da53..d4692185 100644 --- a/epiphany-core/src/pipeline_documents.rs +++ b/epiphany-core/src/pipeline_documents.rs @@ -5,6 +5,16 @@ //! 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. +//! +//! **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 reaches the store through the +//! admission path, never by registering or preparing a pipeline type itself. use crate::pipeline_store::PipelineRefusal; use anyhow::Result; @@ -24,6 +34,18 @@ impl Bounded for Option { } } +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 { @@ -60,33 +82,53 @@ fn hex(field: &str, value: &str, lengths: std::ops::RangeInclusive) -> Re Ok(()) } -/// `` and `` key segments: `[A-Za-z0-9._-]{1,64}`. -fn label<'a>(field: &str, value: &'a str) -> Result<&'a str, PipelineRefusal> { +/// A key label: `[A-Za-z0-9_-]{1,64}`. Dots are excluded so that a composed key +/// segments unambiguously; see `parent_cut` and the finding key. +fn label_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { let valid = value .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + .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(value) + Ok(()) } -fn org_repo(field: &str, value: &str) -> Result<(), PipelineRefusal> { +/// 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> { + if value.is_empty() || value.len() > 64 { + return Err(format_error(field, value)); + } + for part in value.split('.') { + label_text(field, part)?; + } + Ok(()) +} + +fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { match value.split_once('/') { - Some((org, repo)) if !org.is_empty() && !repo.is_empty() && !repo.contains('/') => Ok(()), + Some((org, repo)) + if !org.is_empty() && !repo.is_empty() && !repo.contains('/') && value.len() <= 200 => + { + Ok(()) + } _ => Err(format_error(field, value)), } } macro_rules! bounded_text { - ($($(#[$doc:meta])* $name:ident = $limit:literal;)*) => {$( + ($($(#[$doc:meta])* $name:ident = $limit:literal, $check:expr;)*) => {$( $(#[$doc])* #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] + #[schemars(extend("maxLength" = $limit))] pub struct $name(pub String); impl Bounded for $name { fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - bound(field, $limit, self.0.len()) + let check: fn(&str, &str) -> Result<(), PipelineRefusal> = $check; + check(field, &self.0) } } impl From<&str> for $name { @@ -97,29 +139,35 @@ macro_rules! bounded_text { )*}; } -bounded_text! { - /// Text of at most 200 UTF-8 bytes. - Short = 200; - /// Text of at most 1,000 UTF-8 bytes. - Line = 1000; - /// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`. - Para = 4000; +fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> { + move |field: &str, value: &str| bound(field, limit, value.len()) } -/// A git commit id: 7-40 lowercase hex characters. -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] -#[serde(transparent)] -pub struct Sha(pub String); - -impl Bounded for Sha { - fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { - hex(field, &self.0, 7..=40) - } +bounded_text! { + /// Text of at most 200 UTF-8 bytes. + Short = 200, |field, value| within(200)(field, value); + /// 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 GitHub repository as `Org/Repo`. + OrgRepo = 200, org_repo_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); } /// 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 { @@ -131,17 +179,6 @@ impl Bounded for Date { } } -macro_rules! bounded_struct { - ($($ty:ident { $($field:ident $([$max:literal])?),* $(,)? })*) => {$( - impl Bounded for $ty { - fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { - $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* - Ok(()) - } - } - )*}; -} - macro_rules! bounded_field { ($value:expr, $field:expr) => { Bounded::validate(&$value, $field)? @@ -151,11 +188,25 @@ macro_rules! bounded_field { }; } +/// 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),* $(,)? })*) => {$( + ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? })*) => {$( $(#[$attr])* #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] - pub struct $name { $(pub $field: $ty),* } + 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)?);)* + Ok(()) + } + } )*}; } @@ -163,94 +214,138 @@ 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(()) + } + } )*}; } unit_enums! { EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } Faculty { SelfFaculty, Imagination, Hands, Soul, MindSteward, Eyes, Operator } - LandedNameKind { Module, Type, Function, Constant, Test, Schema, Binary, File } ClaimOutcome { Holds, Falsified, Unproven } FindingConfidence { Confirmed, Plausible } FindingSeverity { Blocker, High, Medium, Low } } value_types! { - pub struct PipelineRef { kind: PipelineKind, id: Short } 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: Short, commit: Sha, kind: PipelineKind, id: Short, payload_sha256: String } + 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: Short, statement: Line } - pub struct QuestionOption { label: Short, text: Line } + 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, outputs: Vec, derived_state: Vec, - forbidden_writers: Vec, shared_paths: Vec, deletion_line: Line, + 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, tests: Vec, negative: Vec, operator: Vec, + builds: Vec[64], tests: Vec[64], + negative: Vec[64], operator: Vec[64], + } + // 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 SubtractionEstimate { + lines_removed: u32, lines_added: u32, removed: Vec[64], added: Vec[64], } - pub struct SubtractionEstimate { lines_removed: u32, lines_added: u32, removed: Vec, added: Vec } pub struct ReportCommit { sha: Sha, subject: Line, builds: bool } pub struct MutationRecord { rule: Line, mutation: Line, failed_as_expected: bool } pub struct Deviation { what: Line, why: Line } + // As for `SubtractionEstimate`: D1 tables no maximum, so these lists take + // the shared list default. pub struct StructuralDelta { lines_added: u32, lines_removed: u32, - dependencies_added: Vec, dependencies_removed: Vec, - formats_added: Vec, formats_removed: Vec, - targets_added: Vec, targets_removed: Vec, + dependencies_added: Vec[64], dependencies_removed: Vec[64], + formats_added: Vec[64], formats_removed: Vec[64], + targets_added: Vec[64], targets_removed: Vec[64], } - pub struct LandedName { name: Short, kind: LandedNameKind, location: CodeLocation } - pub struct VerdictClaim { claim: Line, outcome: ClaimOutcome, evidence: Vec, findings: Vec } + /// A name the cut landed, and where it lives. + pub struct LandedName { name: Short, path: Short } + pub struct VerdictClaim { claim: Line, outcome: ClaimOutcome, evidence: Vec[8], findings: Vec[16] } - pub struct PipelineCampaign { slug: Short, title: Short, repos: Vec, working_branch: Short, target_doc: DocRef } + pub struct PipelineCampaign { slug: Slug, title: Short, repos: Vec[8], working_branch: Short, target_doc: DocRef } pub struct PipelineTarget { - campaign: Short, revision: u32, invariants: Vec, not_in_scope: Vec, - canonical_implementations: Vec, doc: DocRef, + campaign: Slug, revision: u32, invariants: Vec[32], not_in_scope: Vec[32], + canonical_implementations: Vec[16], doc: DocRef, } pub struct PipelineQuestion { - campaign: Short, label: Short, question: Para, options: Vec, recommended: Short, - depends: Vec, raised_in: Option, asked_on: Date, + campaign: Slug, label: Label, question: Para, options: Vec[8], recommended: Label, + depends: Vec[8], raised_in: Option, asked_on: Date, } pub struct PipelineRuling { - campaign: Short, label: Short, answers: Option, choice: Option, ruling: Para, - operator_quote: Option, ruled_on: Date, precedents: Vec, + campaign: Slug, label: Label, answers: Option, choice: Option