Skip to main content

guix_scheme/
validate.rs

1//! Field validation ported from libguix (`channels.rs`), same rules and
2//! error wording so a migration is behavior-neutral.
3
4use crate::{Channel, Error};
5
6/// Invisible or direction-altering codepoints. The URL is shown to the
7/// user verbatim before confirmation, so what they see must match what
8/// `guix pull` resolves.
9fn is_deceptive_unicode(c: char) -> bool {
10    matches!(c,
11        // Bidi controls (RTL override, embedding, isolates)
12        '\u{200E}' | '\u{200F}'
13        | '\u{202A}'..='\u{202E}'
14        | '\u{2066}'..='\u{2069}'
15        // Zero-width chars
16        | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}'
17        // Line/paragraph separators
18        | '\u{2028}' | '\u{2029}'
19        // Soft hyphen, word joiner, invisible math operators
20        | '\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
111/// Channel names must shape into a Scheme symbol so the embedded
112/// `(name 'foo)` form parses. Same rule as libguix `is_valid_channel_name`.
113pub 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}