1use crate::{Channel, Error};
5
6fn is_deceptive_unicode(c: char) -> bool {
10 matches!(c,
11 '\u{200E}' | '\u{200F}'
13 | '\u{202A}'..='\u{202E}'
14 | '\u{2066}'..='\u{2069}'
15 | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'
17 | '\u{2028}' | '\u{2029}'
19 | '\u{00AD}' | '\u{2060}' | '\u{2061}'..='\u{2064}'
21 )
22}
23
24pub fn url(url: &str) -> Result<(), Error> {
25 const SCHEMES: &[&str] = &["https://", "http://", "git://", "ssh://", "file://"];
26 if !SCHEMES.iter().any(|s| url.starts_with(s)) {
27 return Err(Error::Invalid {
28 field: "url".into(),
29 reason: "must start with https://, http://, git://, ssh:// or file://".into(),
30 });
31 }
32 if url.len() > 2048 {
33 return Err(Error::Invalid {
34 field: "url".into(),
35 reason: format!("length {} exceeds 2048", url.len()),
36 });
37 }
38 for c in url.chars() {
39 if c.is_control() {
40 return Err(Error::Invalid {
41 field: "url".into(),
42 reason: format!("contains control char (U+{:04X})", c as u32),
43 });
44 }
45 if is_deceptive_unicode(c) {
46 return Err(Error::Invalid {
47 field: "url".into(),
48 reason: format!(
49 "contains deceptive Unicode codepoint (U+{:04X}) — bidi override, zero-width, or similar",
50 c as u32
51 ),
52 });
53 }
54 }
55 Ok(())
56}
57
58pub fn branch(branch: &str) -> Result<(), Error> {
59 if branch.is_empty() || branch.len() > 200 {
60 return Err(Error::Invalid {
61 field: "branch".into(),
62 reason: format!("length {} out of range 1..=200", branch.len()),
63 });
64 }
65 for c in branch.chars() {
66 let ok = c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-' | '+');
67 if !ok {
68 return Err(Error::Invalid {
69 field: "branch".into(),
70 reason: format!("contains disallowed character `{c}`"),
71 });
72 }
73 }
74 Ok(())
75}
76
77pub fn introduction_commit(commit: &str) -> Result<(), Error> {
78 if commit.len() < 7 || commit.len() > 64 {
79 return Err(Error::Invalid {
80 field: "introduction.commit".into(),
81 reason: format!("length {} out of range 7..=64", commit.len()),
82 });
83 }
84 if !commit.chars().all(|c| c.is_ascii_hexdigit()) {
85 return Err(Error::Invalid {
86 field: "introduction.commit".into(),
87 reason: "must be hex digits only".into(),
88 });
89 }
90 Ok(())
91}
92
93pub fn introduction_fingerprint(fpr: &str) -> Result<(), Error> {
94 if fpr.len() < 8 || fpr.len() > 128 {
95 return Err(Error::Invalid {
96 field: "introduction.fingerprint".into(),
97 reason: format!("length {} out of range 8..=128", fpr.len()),
98 });
99 }
100 for c in fpr.chars() {
101 if !(c.is_ascii_hexdigit() || c == ' ') {
102 return Err(Error::Invalid {
103 field: "introduction.fingerprint".into(),
104 reason: format!("contains disallowed character `{c}`"),
105 });
106 }
107 }
108 Ok(())
109}
110
111pub fn channel_name(name: &str) -> Result<(), Error> {
114 let ok = !name.is_empty()
115 && name
116 .chars()
117 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.'));
118 if ok {
119 Ok(())
120 } else {
121 Err(Error::Invalid {
122 field: "name".into(),
123 reason: format!(
124 "channel name `{name}` contains characters that aren't valid in a Scheme symbol"
125 ),
126 })
127 }
128}
129
130pub fn channel_fields(ch: &Channel) -> Result<(), Error> {
131 channel_name(&ch.name)?;
132 url(&ch.url)?;
133 if let Some(b) = &ch.branch {
134 branch(b)?;
135 }
136 if let Some(c) = &ch.introduction_commit {
137 introduction_commit(c)?;
138 }
139 if let Some(f) = &ch.introduction_fingerprint {
140 introduction_fingerprint(f)?;
141 }
142 Ok(())
143}