Skip to main content

ens_normalize_rs/
validate.rs

1use crate::{
2    constants, static_data::spec_json, utils, CodePoint, CodePointsSpecs, CollapsedEnsNameToken,
3    CurrableError, DisallowedSequence, EnsNameToken, ParsedGroup, ProcessError, TokenizedLabel,
4    TokenizedName,
5};
6use itertools::Itertools;
7use std::collections::HashSet;
8pub type LabelType = spec_json::GroupName;
9
10/// Represents a validated ENS label as result of the `validate_label` function.
11/// Contains the original tokenized label and the type of the label.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ValidatedLabel {
14    pub tokens: Vec<EnsNameToken>,
15    pub label_type: LabelType,
16}
17
18pub fn validate_name(
19    name: &TokenizedName,
20    specs: &CodePointsSpecs,
21) -> Result<Vec<ValidatedLabel>, ProcessError> {
22    if name.is_empty() {
23        return Ok(vec![]);
24    }
25    let labels = name
26        .iter_labels()
27        .map(|label| validate_label(label, specs))
28        .collect::<Result<Vec<_>, _>>()?;
29    Ok(labels)
30}
31
32/// Validates a tokenized ENS label according to the ENSIP 15 specification
33/// https://docs.ens.domains/ensip/15#validate
34pub fn validate_label(
35    label: TokenizedLabel<'_>,
36    specs: &CodePointsSpecs,
37) -> Result<ValidatedLabel, ProcessError> {
38    non_empty(&label)?;
39    check_token_types(&label)?;
40    if label.is_fully_emoji() {
41        return Ok(ValidatedLabel {
42            tokens: label.tokens.to_owned(),
43            label_type: LabelType::Emoji,
44        });
45    };
46    underscore_only_at_beginning(&label)?;
47    if label.is_fully_ascii() {
48        no_hyphen_at_second_and_third(&label)?;
49        return Ok(ValidatedLabel {
50            tokens: label.tokens.to_owned(),
51            label_type: LabelType::Ascii,
52        });
53    }
54    check_fenced(&label, specs)?;
55    check_cm_leading_emoji(&label, specs)?;
56    let group = check_and_get_group(&label, specs)?;
57    Ok(ValidatedLabel {
58        tokens: label.tokens.to_owned(),
59        label_type: group.name,
60    })
61}
62
63fn non_empty(label: &TokenizedLabel) -> Result<(), ProcessError> {
64    let non_ignored_token_exists = label.tokens.iter().any(|token| !token.is_ignored());
65    if !non_ignored_token_exists {
66        return Err(ProcessError::DisallowedSequence(
67            DisallowedSequence::EmptyLabel,
68        ));
69    }
70    Ok(())
71}
72
73fn check_token_types(label: &TokenizedLabel) -> Result<(), ProcessError> {
74    if let Some(token) = label
75        .tokens
76        .iter()
77        .find(|token| token.is_disallowed() || token.is_stop())
78    {
79        let cps = token.cps();
80        let maybe_invisible_cp = cps.iter().find(|cp| {
81            *cp == &constants::CP_ZERO_WIDTH_JOINER || *cp == &constants::CP_ZERO_WIDTH_NON_JOINER
82        });
83        if let Some(invisible_cp) = maybe_invisible_cp {
84            return Err(ProcessError::DisallowedSequence(
85                DisallowedSequence::InvisibleCharacter(*invisible_cp),
86            ));
87        } else {
88            return Err(ProcessError::DisallowedSequence(
89                DisallowedSequence::Invalid(utils::cps2str(&cps)),
90            ));
91        }
92    }
93    Ok(())
94}
95
96fn underscore_only_at_beginning(label: &TokenizedLabel) -> Result<(), ProcessError> {
97    let leading_underscores = label
98        .iter_cps()
99        .take_while(|cp| *cp == constants::CP_UNDERSCORE)
100        .count();
101    let underscore_in_middle = label
102        .iter_cps()
103        .enumerate()
104        .skip(leading_underscores)
105        .find(|(_, cp)| *cp == constants::CP_UNDERSCORE);
106    if let Some((index, _)) = underscore_in_middle {
107        return Err(ProcessError::CurrableError {
108            inner: CurrableError::UnderscoreInMiddle,
109            index,
110            sequence: utils::cps2str(&[constants::CP_UNDERSCORE]),
111            maybe_suggest: Some("".to_string()),
112        });
113    }
114    Ok(())
115}
116
117// The 3rd and 4th characters must not both be 2D (-) HYPHEN-MINUS.
118// Must not match /^..--/
119// Examples: "ab-c" and "---a"are valid, "xn--" and ---- are invalid.
120fn no_hyphen_at_second_and_third(label: &TokenizedLabel) -> Result<(), ProcessError> {
121    if label.iter_cps().nth(2) == Some(constants::CP_HYPHEN)
122        && label.iter_cps().nth(3) == Some(constants::CP_HYPHEN)
123    {
124        return Err(ProcessError::CurrableError {
125            inner: CurrableError::HyphenAtSecondAndThird,
126            index: 2,
127            sequence: utils::cps2str(&[constants::CP_HYPHEN, constants::CP_HYPHEN]),
128            maybe_suggest: Some("".to_string()),
129        });
130    }
131    Ok(())
132}
133
134fn check_fenced(label: &TokenizedLabel, specs: &CodePointsSpecs) -> Result<(), ProcessError> {
135    if let Some(first_cp) = label.iter_cps().next() {
136        if specs.is_fenced(first_cp) {
137            return Err(ProcessError::CurrableError {
138                inner: CurrableError::FencedLeading,
139                index: 0,
140                sequence: utils::cps2str(&[first_cp]),
141                maybe_suggest: Some("".to_string()),
142            });
143        }
144    }
145    if let Some(last_cp) = label.iter_cps().last() {
146        if specs.is_fenced(last_cp) {
147            return Err(ProcessError::CurrableError {
148                inner: CurrableError::FencedTrailing,
149                index: label.iter_cps().count() - 1,
150                sequence: utils::cps2str(&[last_cp]),
151                maybe_suggest: Some("".to_string()),
152            });
153        }
154    }
155
156    for (i, window) in label.iter_cps().tuple_windows().enumerate() {
157        let (one, two) = window;
158        if specs.is_fenced(one) && specs.is_fenced(two) {
159            return Err(ProcessError::CurrableError {
160                inner: CurrableError::FencedConsecutive,
161                index: i,
162                sequence: utils::cps2str(&[one, two]),
163                maybe_suggest: Some(utils::cp2str(one)),
164            });
165        }
166    }
167    Ok(())
168}
169
170fn check_cm_leading_emoji(
171    label: &TokenizedLabel,
172    specs: &CodePointsSpecs,
173) -> Result<(), ProcessError> {
174    let mut index = 0;
175    let collapsed = label.collapse_into_text_or_emoji();
176    for (i, token) in collapsed.iter().enumerate() {
177        if let CollapsedEnsNameToken::Text(token) = token {
178            if let Some(cp) = token.cps.first() {
179                if specs.is_cm(*cp) {
180                    if i == 0 {
181                        return Err(ProcessError::CurrableError {
182                            inner: CurrableError::CmStart,
183                            index,
184                            sequence: utils::cps2str(&[*cp]),
185                            maybe_suggest: Some("".to_string()),
186                        });
187                    } else {
188                        return Err(ProcessError::CurrableError {
189                            inner: CurrableError::CmAfterEmoji,
190                            index,
191                            sequence: utils::cps2str(&[*cp]),
192                            maybe_suggest: Some("".to_string()),
193                        });
194                    }
195                }
196            }
197        }
198        index += token.input_size();
199    }
200
201    Ok(())
202}
203
204fn check_and_get_group(
205    label: &TokenizedLabel,
206    specs: &CodePointsSpecs,
207) -> Result<ParsedGroup, ProcessError> {
208    let cps = label.get_cps_of_not_ignored_text();
209    let unique_cps = cps
210        .clone()
211        .into_iter()
212        .collect::<HashSet<_>>()
213        .into_iter()
214        .collect::<Vec<_>>();
215    let group = determine_group(&unique_cps, specs).cloned()?;
216    check_group(&group, &cps, specs)?;
217    check_whole(&group, &unique_cps, specs)?;
218    Ok(group)
219}
220
221fn check_group(
222    group: &ParsedGroup,
223    cps: &[CodePoint],
224    specs: &CodePointsSpecs,
225) -> Result<(), ProcessError> {
226    for cp in cps.iter() {
227        if !group.contains_cp(*cp) {
228            return Err(ProcessError::Confused(format!(
229                "symbol {} not present in group {}",
230                utils::cp2str(*cp),
231                group.name
232            )));
233        }
234    }
235    if group.cm_absent {
236        let decomposed = utils::nfd_cps(cps, specs);
237        let mut i = 1;
238        let e = decomposed.len();
239        while i < e {
240            if specs.is_nsm(decomposed[i]) {
241                let mut j = i + 1;
242                while j < e && specs.is_nsm(decomposed[j]) {
243                    if j - i + 1 > specs.nsm_max() as usize {
244                        return Err(ProcessError::DisallowedSequence(
245                            DisallowedSequence::NsmTooMany,
246                        ));
247                    }
248                    for k in i..j {
249                        if decomposed[k] == decomposed[j] {
250                            return Err(ProcessError::DisallowedSequence(
251                                DisallowedSequence::NsmRepeated,
252                            ));
253                        }
254                    }
255                    j += 1;
256                }
257                i = j;
258            }
259            i += 1;
260        }
261    }
262    Ok(())
263}
264
265fn check_whole(
266    group: &ParsedGroup,
267    unique_cps: &[CodePoint],
268    specs: &CodePointsSpecs,
269) -> Result<(), ProcessError> {
270    let mut shared = Vec::new();
271    let mut universe = Vec::new();
272    let mut universe_len = 0usize;
273
274    for &cp in unique_cps {
275        if let Some(whole) = specs.whole_for_confusable(cp) {
276            let complements = whole
277                .complements
278                .get(&cp)
279                .expect("confusable codepoint must have complements");
280
281            if universe_len == 0 {
282                universe_len = complements.len();
283                universe = complements.to_vec();
284            } else {
285                let mut next = 0;
286                for i in 0..universe_len {
287                    if complements.binary_search(&universe[i]).is_ok() {
288                        universe[next] = universe[i];
289                        next += 1;
290                    }
291                }
292                universe_len = next;
293                universe.truncate(universe_len);
294            }
295
296            if universe_len == 0 {
297                return Ok(());
298            }
299        } else if specs.is_unique_non_confusable(cp) {
300            return Ok(());
301        } else {
302            shared.push(cp);
303        }
304    }
305
306    if universe_len > 0 {
307        for &group_idx in &universe[..universe_len] {
308            let other = specs.group_at(group_idx);
309            if other.contains_all_cps(&shared) {
310                return Err(ProcessError::ConfusedGroups {
311                    group1: group.name.to_string(),
312                    group2: other.name.to_string(),
313                });
314            }
315        }
316    }
317
318    Ok(())
319}
320
321fn determine_group<'a>(
322    unique_cps: &'a [CodePoint],
323    specs: &'a CodePointsSpecs,
324) -> Result<&'a ParsedGroup, ProcessError> {
325    specs
326        .groups_for_cps(unique_cps)
327        .next()
328        .ok_or(ProcessError::Confused(format!(
329            "no group found for {:?}",
330            unique_cps
331        )))
332}
333
334#[cfg(test)]
335mod tests {
336    use crate::TokenizedName;
337
338    use super::*;
339    use pretty_assertions::assert_eq;
340    use rstest::{fixture, rstest};
341
342    #[fixture]
343    #[once]
344    fn specs() -> CodePointsSpecs {
345        CodePointsSpecs::default()
346    }
347
348    #[rstest]
349    // success
350    #[case::hello("hello", Ok(LabelType::Ascii))]
351    #[case::latin("E︎̃", Ok(LabelType::Other("Latin".to_string())))]
352    #[case::cyrillic("всем-привет", Ok(LabelType::Other("Cyrillic".to_string())))]
353    #[case::with_fenced_in_middle("a・a’s", Ok(LabelType::Other("Han".to_string())))]
354    #[case::ascii_with_hyphen("ab-c", Ok(LabelType::Ascii))]
355    // errors
356    #[case::hyphen_at_second_and_third("ab--", Err(ProcessError::CurrableError {
357        inner: CurrableError::HyphenAtSecondAndThird,
358        index: 2,
359        sequence: "--".to_string(),
360        maybe_suggest: Some("".to_string())
361    }))]
362    #[case::fenced_leading("’85", Err(ProcessError::CurrableError {
363        inner: CurrableError::FencedLeading,
364        index: 0,
365        sequence: "’".to_string(),
366        maybe_suggest: Some("".to_string())
367    }))]
368    #[case::fenced_contiguous("a・・a", Err(ProcessError::CurrableError {
369        inner: CurrableError::FencedConsecutive,
370        index: 1,
371        sequence: "・・".to_string(),
372        maybe_suggest: Some("・".to_string())
373    }))]
374    #[case::cm_after_emoji("😎😎😎😎😎😎😎😎\u{300}hello", Err(ProcessError::CurrableError {
375        inner: CurrableError::CmAfterEmoji,
376        index: 8,
377        sequence: "\u{300}".to_string(),
378        maybe_suggest: Some("".to_string())
379    }))]
380    #[case::cm_leading("\u{300}hello", Err(ProcessError::CurrableError {
381        inner: CurrableError::CmStart,
382        index: 0,
383        sequence: "\u{300}".to_string(),
384        maybe_suggest: Some("".to_string())
385    }))]
386    fn test_validate_and_get_type(
387        #[case] input: &str,
388        #[case] expected: Result<LabelType, ProcessError>,
389        specs: &CodePointsSpecs,
390    ) {
391        let name = TokenizedName::from_input(input, specs, true).unwrap();
392        let label = name.iter_labels().next().unwrap();
393        let result = validate_label(label, specs);
394        assert_eq!(
395            result.clone().map(|v| v.label_type),
396            expected,
397            "{:?}",
398            result
399        );
400    }
401
402    #[rstest]
403    #[case::emoji("\"Emoji\"", LabelType::Emoji)]
404    #[case::ascii("\"ASCII\"", LabelType::Ascii)]
405    #[case::greek("\"Greek\"", LabelType::Greek)]
406    #[case::other("\"FooBar\"", LabelType::Other("FooBar".to_string()))]
407    fn test_deserialize_label_type(#[case] input: &str, #[case] expected: LabelType) {
408        let result: LabelType = serde_json::from_str(input).unwrap();
409        assert_eq!(result, expected);
410    }
411}