use crate::{Channel, Error};
fn is_deceptive_unicode(c: char) -> bool {
matches!(c,
'\u{200E}' | '\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'
| '\u{2028}' | '\u{2029}'
| '\u{00AD}' | '\u{2060}' | '\u{2061}'..='\u{2064}'
)
}
pub fn url(url: &str) -> Result<(), Error> {
const SCHEMES: &[&str] = &["https://", "http://", "git://", "ssh://", "file://"];
if !SCHEMES.iter().any(|s| url.starts_with(s)) {
return Err(Error::Invalid {
field: "url".into(),
reason: "must start with https://, http://, git://, ssh:// or file://".into(),
});
}
if url.len() > 2048 {
return Err(Error::Invalid {
field: "url".into(),
reason: format!("length {} exceeds 2048", url.len()),
});
}
for c in url.chars() {
if c.is_control() {
return Err(Error::Invalid {
field: "url".into(),
reason: format!("contains control char (U+{:04X})", c as u32),
});
}
if is_deceptive_unicode(c) {
return Err(Error::Invalid {
field: "url".into(),
reason: format!(
"contains deceptive Unicode codepoint (U+{:04X}) — bidi override, zero-width, or similar",
c as u32
),
});
}
}
Ok(())
}
pub fn branch(branch: &str) -> Result<(), Error> {
if branch.is_empty() || branch.len() > 200 {
return Err(Error::Invalid {
field: "branch".into(),
reason: format!("length {} out of range 1..=200", branch.len()),
});
}
for c in branch.chars() {
let ok = c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+');
if !ok {
return Err(Error::Invalid {
field: "branch".into(),
reason: format!("contains disallowed character `{c}`"),
});
}
}
Ok(())
}
pub fn introduction_commit(commit: &str) -> Result<(), Error> {
if commit.len() < 7 || commit.len() > 64 {
return Err(Error::Invalid {
field: "introduction.commit".into(),
reason: format!("length {} out of range 7..=64", commit.len()),
});
}
if !commit.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::Invalid {
field: "introduction.commit".into(),
reason: "must be hex digits only".into(),
});
}
Ok(())
}
pub fn introduction_fingerprint(fpr: &str) -> Result<(), Error> {
if fpr.len() < 8 || fpr.len() > 128 {
return Err(Error::Invalid {
field: "introduction.fingerprint".into(),
reason: format!("length {} out of range 8..=128", fpr.len()),
});
}
for c in fpr.chars() {
if !(c.is_ascii_hexdigit() || c == ' ') {
return Err(Error::Invalid {
field: "introduction.fingerprint".into(),
reason: format!("contains disallowed character `{c}`"),
});
}
}
Ok(())
}
pub fn channel_name(name: &str) -> Result<(), Error> {
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.'));
if ok {
Ok(())
} else {
Err(Error::Invalid {
field: "name".into(),
reason: format!(
"channel name `{name}` contains characters that aren't valid in a Scheme symbol"
),
})
}
}
pub fn channel_fields(ch: &Channel) -> Result<(), Error> {
channel_name(&ch.name)?;
url(&ch.url)?;
if let Some(b) = &ch.branch {
branch(b)?;
}
if let Some(c) = &ch.introduction_commit {
introduction_commit(c)?;
}
if let Some(f) = &ch.introduction_fingerprint {
introduction_fingerprint(f)?;
}
Ok(())
}