Cut RS-L third fix: OrgRepo compares by identity, .git refusal is
case-insensitive, the bidi set is pinned per-point, and the title rule refuses two more separators Soul's RS-L second-fix pass found identity() exposed but never adopted: key derivation and every derived PartialEq/Ord/Hash still ran on the raw string, so GameCult/Epiphany and gamecult/epiphany were two stewardships and two store keys (S-1). The .git refusal was case-sensitive, so a case variant reached an identity() the validator itself refused (S-2). Only U+202E was pinned in the bidi match arm; every other code point's test fixture was a lone character the alphanumeric rule already refused, so cargo-mutants could not see a class-member deletion (S-3). is_control() is Cc only, so U+2028/U+2029 (Zl/Zp) slipped through the widened title rule and a one-line field could carry a hard line break (S-4). The Title schema description lost the bytes-versus-characters caveat the original RS-L cut carried (S-5). - S-1: `OrgRepo` is pulled out of the `bounded_text!` macro (which has no per-member override) and given hand-written PartialEq, Eq, Hash, PartialOrd and Ord, all defined over identity(). Every other `bounded_text!` member keeps the macro's derives untouched. Key derivation for Stewardship and HandOff (lib.rs's Stewardship/HandOff arms of pipeline_key) now escapes repo.identity() instead of the raw string. - S-2: org_repo_text's `.git` suffix check is ASCII-case-insensitive. identity() stays valid for every value validate_org_repo accepts, since lowercasing never changes the outcome of any rule in the grammar. - S-3: title_text's control check also refuses U+2028 and U+2029 alongside is_control(); everything else, including ZWJ/ZWNJ, is unaffected. - S-5: Title's doc comment (the schema description) regains "enforced by admission; JSON Schema length counts characters". Tests add: OrgRepo equality/ordering by identity, a HashMap and a BTreeMap each keyed by both spellings holding one entry, identity() always itself valid, case-insensitive .git fixtures (a/b.GIT, a/.GIT, A/B.GIT), one Stewardship store key for both spellings, all eleven bidi code points pinned with embedded (`a<cp>b`) fixtures instead of lone characters, and U+2028/U+2029 refused embedded. Schemas regenerated in the next commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0d1e592b9a
commit
0631cbefb6
1 changed files with 183 additions and 42 deletions
|
|
@ -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<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.identity().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for OrgRepo {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
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<String, PipelineRefus
|
|||
D::Stewardship(value) => {
|
||||
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<String, PipelineRefus
|
|||
(
|
||||
"hand_off.from_instance",
|
||||
value.from_instance.0.as_str(),
|
||||
local(&key_field, &[&key_segment(&value.to_instance.0), &key_segment(&value.repo.0), &value.handed_on.0])?,
|
||||
local(
|
||||
&key_field,
|
||||
&[&key_segment(&value.to_instance.0), &key_segment(&value.repo.identity()), &value.handed_on.0],
|
||||
)?,
|
||||
)
|
||||
}
|
||||
D::Target(value) => (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
|
||||
// `<receiver>.<repo>.<date>`, the repo `GameCult_-Epiphany` and the
|
||||
// `<receiver>.<repo>.<date>`, the repo `gamecult_-epiphany` and the
|
||||
// date ten bytes, so the receiver fills the rest; a stewardship local is
|
||||
// `<repo escaped>.n<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());
|
||||
|
|
|
|||
Reference in a new issue