diff --git a/epiphany-pipeline/src/lib.rs b/epiphany-pipeline/src/lib.rs index beef8677..39e79e32 100644 --- a/epiphany-pipeline/src/lib.rs +++ b/epiphany-pipeline/src/lib.rs @@ -157,8 +157,10 @@ fn dotted_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { /// 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`. The repo's own byte alphabet excludes `/`, so a second `/` -/// inside it is already refused by the byte check; no separate +/// 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)| { @@ -170,7 +172,7 @@ fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { && repo.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) && repo != "." && repo != ".." - && !repo.ends_with(".git") + && !(repo.len() >= 4 && repo[repo.len() - 4..].eq_ignore_ascii_case(".git")) }); if valid { Ok(()) @@ -205,13 +207,18 @@ fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> { } /// A title holds at least one `char::is_alphanumeric()` character, no -/// `char::is_control()` character, 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 three tests is allowed. +/// `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()); + let control = value + .chars() + .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')); let bidi = value.chars().any(|character| { matches!( character, @@ -227,9 +234,10 @@ fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> { 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. 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`). + /// 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. @@ -240,12 +248,6 @@ bounded_text! { Label = 64, label_text; /// A campaign slug or key segment: label parts joined by `.`. Slug = 64, dotted_text; - /// 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`. Compare and key repositories - /// only through `OrgRepo::identity()`. - OrgRepo = 140, 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. @@ -269,6 +271,66 @@ impl 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. +/// +/// 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. +#[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 @@ -281,9 +343,11 @@ impl OrgRepo { } /// The one canonical key for an `OrgRepo`: the value, ASCII-lowercased. - /// Every comparison and every lookup keyed by repository identity goes - /// through this, so `GameCult/Epiphany` and `gamecult/epiphany` are the - /// same repository. + /// 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() } @@ -841,7 +905,11 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result { org_repo_text("stewardship.repo", &value.repo.0)?; let sequence = format!("n{}", value.sequence); - ("stewardship.instance", value.instance.0.as_str(), local(&key_field, &[&key_segment(&value.repo.0), &sequence])?) + ( + "stewardship.instance", + value.instance.0.as_str(), + local(&key_field, &[&key_segment(&value.repo.identity()), &sequence])?, + ) } D::HandOff(value) => { dotted_text("hand_off.to_instance", &value.to_instance.0)?; @@ -850,7 +918,10 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, &[&format!("r{}", value.revision)])?), @@ -1084,12 +1155,12 @@ mod tests { (D::Stewardship(PipelineStewardship { instance: slug(INSTANCE), repo: repo(), sequence: 1, assigned_on: date(), note: "The Eureka campaign repo.".into(), - }), format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1")), + }), format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")), (D::HandOff(PipelineHandOff { from_instance: slug(INSTANCE), to_instance: slug("thought-cage"), repo: repo(), documents: vec![s(CAMPAIGN), id("ruling", "R8")], reason: "The workstation mind takes the campaign.".into(), handed_on: date(), - }), format!("{INSTANCE}:hand_off:thought-cage.GameCult_-Epiphany.{}", date().0)), + }), format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany.{}", date().0)), ] } @@ -1280,11 +1351,11 @@ mod tests { let dotted_repo = handed("thought-cage", "GameCult/Epiphany.thing"); assert_eq!( dotted_receiver, - Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.Epiphany_-thing.2026-09-15")) + Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.epiphany_-thing.2026-09-15")) ); assert_eq!( dotted_repo, - Ok(format!("{INSTANCE}:hand_off:thought-cage.GameCult_-Epiphany_dthing.2026-09-15")) + Ok(format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany_dthing.2026-09-15")) ); assert_ne!(dotted_receiver, dotted_repo, "a dotted repo and a dotted receiver cannot claim one key"); @@ -1557,7 +1628,7 @@ mod tests { let stewardship = stewardship_sample(); assert_eq!(stewardship.repo, OrgRepo("GameCult/Epiphany".into())); let key = pipeline_key(&PipelineDocument::Stewardship(stewardship.clone())); - assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"))); + assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"))); assert!(!key.unwrap().contains('/'), "no key segment carries a slash"); // The escape is not a cosmetic substitution, and the pair that proves @@ -1574,11 +1645,11 @@ mod tests { let underscored_repo = keyed("GameCult/Epiphany_thing"); assert_eq!( dotted_repo, - Ok(format!("{INSTANCE}:stewardship:GameCult_-Epiphany_dthing.n1")) + Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany_dthing.n1")) ); assert_eq!( underscored_repo, - Ok(format!("{INSTANCE}:stewardship:GameCult_-Epiphany__thing.n1")) + Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany__thing.n1")) ); assert_ne!(dotted_repo, underscored_repo, "two repos cannot claim one key"); @@ -1589,6 +1660,17 @@ mod tests { Err(PipelineRefusal::InvalidFormat { field: "stewardship.repo".into(), value: "Epiphany".into() }) ); + // S-1: key derivation goes through `identity()`, so a case variant of + // the same repository is one store key, not two stewardships of what + // is really one repo. + let mut upper = stewardship.clone(); + upper.repo = OrgRepo("gamecult/epiphany".into()); + assert_eq!( + pipeline_key(&PipelineDocument::Stewardship(upper)), + pipeline_key(&PipelineDocument::Stewardship(stewardship.clone())), + "GameCult/Epiphany and gamecult/epiphany must derive one store key" + ); + // An escaped repo is still bound by the key's segment rules. let mut long = stewardship; long.repo = OrgRepo(format!("GameCult/{}", "a".repeat(60))); @@ -1608,7 +1690,7 @@ mod tests { fn hand_off_names_both_instances() { let key = |hand_off: PipelineHandOff| pipeline_key(&PipelineDocument::HandOff(hand_off)); let base = key(hand_off_sample()).expect("the sample keys"); - assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.GameCult_-Epiphany.2026-09-15")); + assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany.2026-09-15")); let mut other_sender = hand_off_sample(); other_sender.from_instance = slug("thought-cage"); @@ -1630,7 +1712,7 @@ mod tests { dotted_receiver.to_instance = slug("thought-cage.GameCult"); assert_eq!( key(dotted_receiver), - Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.GameCult_-Epiphany.2026-09-15")) + Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.gamecult_-epiphany.2026-09-15")) ); // The escape's `_` half is not only for repos: a receiver `a_db` and a @@ -2074,13 +2156,13 @@ mod tests { }; let first = stewarded(1); let second = stewarded(2); - assert_eq!(first, format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1")); - assert_eq!(second, format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2")); + assert_eq!(first, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")); + assert_eq!(second, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2")); let resolved = resolution_of(PipelineKind::Stewardship, &first).expect("a resolution of a stewardship keys"); let resolved_second = resolution_of(PipelineKind::Stewardship, &second).expect("the second assignment resolves"); - assert_eq!(resolved, format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1")); - assert_eq!(resolved_second, format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n2.n1")); + assert_eq!(resolved, format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1")); + assert_eq!(resolved_second, format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n2.n1")); assert_ne!(resolved, resolved_second, "each assignment keeps its own resolution"); for (key, subject) in [(&resolved, &first), (&resolved_second, &second)] { @@ -2128,10 +2210,10 @@ mod tests { // The bound is 64 exactly, on both sides, whether the part that fills // it is a plain label or an escaped repo. A hand-off local is - // `..`, the repo `GameCult_-Epiphany` and the + // `..`, the repo `gamecult_-epiphany` and the // date ten bytes, so the receiver fills the rest; a stewardship local is // `.n`, so `GameCult_-` and `.n1` bound the name. - let hand_off_rest = "GameCult_-Epiphany".len() + ".".len() + date().0.len() + ".".len(); + let hand_off_rest = "gamecult_-epiphany".len() + ".".len() + date().0.len() + ".".len(); let stewardship_rest = "GameCult_-".len() + ".n1".len(); let received = |receiver: usize| { let mut hand_off = hand_off_sample(); @@ -2291,8 +2373,8 @@ mod tests { }; let first = stewarded("GameCult/Epiphany", 1, "2026-09-15"); let second = stewarded("GameCult/Epiphany", 2, "2026-09-16"); - assert_eq!(first, format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1")); - assert_eq!(second, format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2")); + assert_eq!(first, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")); + assert_eq!(second, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2")); assert_ne!(first, second, "two assignments of one repo are two documents"); for (sequence, key) in [(1, &first), (2, &second)] { let (root, local) = pipeline_id("read_back", key, PipelineKind::Stewardship).expect("reads back"); @@ -2312,7 +2394,7 @@ mod tests { stewarded("GameCult/Epiphany_thing", 1, "2026-09-15"), stewarded("GameCult/Huginn", 1, "2026-09-15"), ]; - let prefix = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n"); + let prefix = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n"); let history = [first, second]; let selected = history .iter() @@ -2327,7 +2409,7 @@ mod tests { assert_eq!( stewarded("GameCult/Epiphany", 0, "2026-09-15"), - format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n0"), + format!("{INSTANCE}:stewardship:gamecult_-epiphany.n0"), "the leaf refuses no sequence value" ); } @@ -2661,15 +2743,51 @@ mod tests { // F7 fix batch: a repo name ending in `.git` is refused. repo_refused("a/b.git", "a repo ending in .git is refused"); repo_refused("a/.git", "a repo of exactly .git is still a .git suffix"); + // S-2: the `.git` refusal is ASCII-case-insensitive, so a case variant + // cannot dodge it and reach an `identity()` the validator refuses. + repo_refused("a/b.GIT", "a repo ending in .GIT is refused like .git"); + repo_refused("a/.GIT", "a repo of exactly .GIT is still a .git suffix"); + repo_refused("A/B.GIT", "a mixed-case owner and a .GIT repo both refuse"); + repo_refused("a/b.GiT", "a mixed-case .git suffix is refused"); // identity(): the one canonical key, ASCII-lowercased. Every - // comparison and key derivation goes through it. + // comparison, hash, ordering and key derivation goes through it. assert_eq!( OrgRepo::from("GameCult/Epiphany").identity(), OrgRepo::from("gamecult/epiphany").identity() ); assert_eq!(OrgRepo::from("GameCult/Epiphany").identity(), "gamecult/epiphany"); + // S-1: it is structural, not advisory. `==` on `OrgRepo` compares + // identity, not the raw string, so the two spellings are one + // repository wherever `OrgRepo` is compared, hashed or ordered. + assert_eq!(OrgRepo::from("GameCult/Epiphany"), OrgRepo::from("gamecult/epiphany")); + assert_eq!(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany")), std::cmp::Ordering::Equal); + + let mut hash_map = std::collections::HashMap::new(); + hash_map.insert(OrgRepo::from("GameCult/Epiphany"), 1); + hash_map.insert(OrgRepo::from("gamecult/epiphany"), 2); + assert_eq!(hash_map.len(), 1, "a HashMap keyed by both spellings holds one entry"); + assert_eq!(hash_map.get(&OrgRepo::from("GAMECULT/EPIPHANY")), Some(&2)); + + let mut btree_map = std::collections::BTreeMap::new(); + btree_map.insert(OrgRepo::from("GameCult/Epiphany"), 1); + btree_map.insert(OrgRepo::from("gamecult/epiphany"), 2); + assert_eq!(btree_map.len(), 1, "a BTreeMap keyed by both spellings holds one entry"); + assert_eq!(btree_map.get(&OrgRepo::from("GAMECULT/EPIPHANY")), Some(&2)); + + // `identity()` is always itself a valid `OrgRepo`: lowercasing never + // turns a valid repository into one the grammar refuses, and never + // turns a refused one into one it accepts. + for value in ["GameCult/Epiphany", "A/B", "a-B/c.D_e", "GameCult/Epiphany.GIT"] { + let repo = OrgRepo::from(value); + assert_eq!( + repo.validate_org_repo().is_ok(), + OrgRepo::from(repo.identity().as_str()).validate_org_repo().is_ok(), + "identity() of {value:?} disagreed with the value's own validity" + ); + } + assert_eq!(Label::from("cut-10").validate_label(), Ok(())); let label_refused = |value: &str, why: &str| { let result = Label::from(value).validate_label(); @@ -2747,6 +2865,29 @@ mod tests { refused("\u{a0}"); // NBSP alone refused("\u{1f680}"); // emoji only + // S-3: every one of the eleven bidi control code points is pinned + // with an embedded fixture (real alphanumeric text on both sides), so + // the alphanumeric rule cannot mask a hole in the bidi match arm the + // way a lone-character fixture would. + refused("a\u{061C}b"); // ALM + refused("a\u{200E}b"); // LRM + refused("a\u{200F}b"); // RLM + refused("a\u{202A}b"); // LRE + refused("a\u{202B}b"); // RLE + refused("a\u{202C}b"); // PDF + refused("a\u{202D}b"); // LRO + refused("a\u{202E}b"); // RLO + refused("a\u{2066}b"); // LRI + refused("a\u{2067}b"); // RLI + refused("a\u{2068}b"); // FSI + refused("a\u{2069}b"); // PDI + + // S-4: U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are + // refused inside otherwise-alphanumeric text, not only alone, since + // `char::is_control()` does not reach `Zl`/`Zp`. + refused("a\u{2028}b"); + refused("a\u{2029}b"); + let accepted = |value: &str| { let mut accepted_question = question.clone(); accepted_question.title = Title(value.into());