Skip to main content

backhopper_core/model/
names.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Newtypes for every domain primitive: `String` should never represent a
6//! project name, tag, module, function, or commit SHA.
7
8use std::fmt;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::errors::NameError;
15
16const MAX_PROJECT_NAME_LEN: usize = 64;
17const MAX_SERIES_NAME_LEN: usize = 64;
18const MAX_TAG_NAME_LEN: usize = 128;
19const MAX_TAG_GLOB_LEN: usize = 128;
20const MAX_MODULE_NAME_LEN: usize = 256;
21const MAX_FUNCTION_NAME_LEN: usize = 256;
22const MAX_TYPE_NAME_LEN: usize = 256;
23const MAX_RECORD_NAME_LEN: usize = 256;
24const MAX_FIELD_NAME_LEN: usize = 256;
25const MAX_APPLICATION_NAME_LEN: usize = 128;
26const MAX_BEHAVIOUR_NAME_LEN: usize = 256;
27const MAX_MACRO_NAME_LEN: usize = 256;
28const MAX_DEPENDENCY_NAME_LEN: usize = 128;
29const MAX_DEPENDENCY_VERSION_LEN: usize = 128;
30
31const APPLICATION_PATTERN: &str = "[a-z][a-z0-9_]{0,127}";
32
33const PROJECT_PATTERN: &str = "[a-z][a-z0-9_-]{0,63}";
34const SERIES_PATTERN: &str = "[a-z][a-z0-9_.-]{0,63}";
35
36fn is_project_name_char(c: char) -> bool {
37    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
38}
39
40fn is_series_name_char(c: char) -> bool {
41    is_project_name_char(c) || c == '.'
42}
43
44fn is_application_name_char(c: char) -> bool {
45    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
46}
47
48fn validate_simple_name(
49    kind: &'static str,
50    value: &str,
51    max_len: usize,
52    first_char_ok: fn(char) -> bool,
53    rest_char_ok: fn(char) -> bool,
54    pattern: &'static str,
55) -> Result<(), NameError> {
56    if value.is_empty() {
57        return Err(NameError::Empty { kind });
58    }
59    if value.len() > max_len {
60        return Err(NameError::TooLong {
61            kind,
62            len: value.len(),
63            max: max_len,
64        });
65    }
66    let mut chars = value.chars();
67    let first = chars.next().expect("non-empty");
68    if !first_char_ok(first) {
69        return Err(NameError::PatternMismatch {
70            kind,
71            value: value.to_owned(),
72            pattern,
73        });
74    }
75    for ch in chars {
76        if !rest_char_ok(ch) {
77            return Err(NameError::InvalidCharacter {
78                kind,
79                ch,
80                value: value.to_owned(),
81            });
82        }
83    }
84    Ok(())
85}
86
87fn validate_tag_glob(value: &str) -> Result<(), NameError> {
88    if value.is_empty() {
89        return Err(NameError::Empty { kind: "tag glob" });
90    }
91    if value.len() > MAX_TAG_GLOB_LEN {
92        return Err(NameError::TooLong {
93            kind: "tag glob",
94            len: value.len(),
95            max: MAX_TAG_GLOB_LEN,
96        });
97    }
98    for ch in value.chars() {
99        if ch.is_ascii_control()
100            || ch.is_whitespace()
101            || ch == '/'
102            || ch == '\\'
103            || ch == '\0'
104            || ch == ':'
105            || ch == '~'
106            || ch == '^'
107            || ch == '['
108        {
109            return Err(NameError::InvalidCharacter {
110                kind: "tag glob",
111                ch,
112                value: value.to_owned(),
113            });
114        }
115    }
116    if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
117        return Err(NameError::PatternMismatch {
118            kind: "tag glob",
119            value: value.to_owned(),
120            pattern: "non-empty, no path separators or git ref-magic",
121        });
122    }
123    Ok(())
124}
125
126fn validate_tag_name(value: &str) -> Result<(), NameError> {
127    if value.is_empty() {
128        return Err(NameError::Empty { kind: "tag" });
129    }
130    if value.len() > MAX_TAG_NAME_LEN {
131        return Err(NameError::TooLong {
132            kind: "tag",
133            len: value.len(),
134            max: MAX_TAG_NAME_LEN,
135        });
136    }
137    for ch in value.chars() {
138        if ch.is_ascii_control()
139            || ch.is_whitespace()
140            || ch == '/'
141            || ch == '\\'
142            || ch == '\0'
143            || ch == ':'
144            || ch == '~'
145            || ch == '^'
146            || ch == '?'
147            || ch == '*'
148            || ch == '['
149        {
150            return Err(NameError::InvalidCharacter {
151                kind: "tag",
152                ch,
153                value: value.to_owned(),
154            });
155        }
156    }
157    if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
158        return Err(NameError::PatternMismatch {
159            kind: "tag",
160            value: value.to_owned(),
161            pattern: "non-empty, no path separators or git ref-magic",
162        });
163    }
164    Ok(())
165}
166
167fn validate_erlang_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
168    if value.is_empty() {
169        return Err(NameError::Empty { kind });
170    }
171    if value.len() > max_len {
172        return Err(NameError::TooLong {
173            kind,
174            len: value.len(),
175            max: max_len,
176        });
177    }
178    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
179        return Ok(());
180    }
181    let first = value.chars().next().expect("non-empty");
182    if !(first.is_ascii_lowercase() || first == '_') {
183        return Err(NameError::PatternMismatch {
184            kind,
185            value: value.to_owned(),
186            pattern: "lowercase-led atom or single-quoted",
187        });
188    }
189    let last = value.chars().last().expect("non-empty");
190    let drop_trailing_punct = if matches!(last, '?' | '!') {
191        value.len() - last.len_utf8()
192    } else {
193        value.len()
194    };
195    for ch in value[..drop_trailing_punct].chars() {
196        if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@') {
197            return Err(NameError::InvalidCharacter {
198                kind,
199                ch,
200                value: value.to_owned(),
201            });
202        }
203    }
204    Ok(())
205}
206
207fn validate_module_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
208    if value.is_empty() {
209        return Err(NameError::Empty { kind });
210    }
211    if value.len() > max_len {
212        return Err(NameError::TooLong {
213            kind,
214            len: value.len(),
215            max: max_len,
216        });
217    }
218    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
219        return Ok(());
220    }
221    let first = value.chars().next().expect("non-empty");
222    if !(first.is_ascii_alphabetic() || first == '_') {
223        return Err(NameError::PatternMismatch {
224            kind,
225            value: value.to_owned(),
226            pattern: "Erlang atom or Elixir module name",
227        });
228    }
229    for ch in value.chars() {
230        if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@' || ch == '.') {
231            return Err(NameError::InvalidCharacter {
232                kind,
233                ch,
234                value: value.to_owned(),
235            });
236        }
237    }
238    Ok(())
239}
240
241macro_rules! string_newtype {
242    ($name:ident, $validate:expr) => {
243        #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
244        #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
245        #[serde(transparent)]
246        pub struct $name(Arc<str>);
247
248        impl $name {
249            pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
250                let v = value.into();
251                $validate(&v)?;
252                Ok(Self(Arc::from(v)))
253            }
254
255            pub fn as_str(&self) -> &str {
256                &self.0
257            }
258
259            pub fn into_inner(self) -> String {
260                self.0.to_string()
261            }
262        }
263
264        impl AsRef<str> for $name {
265            fn as_ref(&self) -> &str {
266                &self.0
267            }
268        }
269
270        impl fmt::Display for $name {
271            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272                f.write_str(&self.0)
273            }
274        }
275
276        impl FromStr for $name {
277            type Err = NameError;
278
279            fn from_str(s: &str) -> Result<Self, NameError> {
280                Self::new(s.to_owned())
281            }
282        }
283    };
284}
285
286string_newtype!(ProjectName, |v: &str| {
287    validate_simple_name(
288        "project name",
289        v,
290        MAX_PROJECT_NAME_LEN,
291        |c| c.is_ascii_lowercase(),
292        is_project_name_char,
293        PROJECT_PATTERN,
294    )
295});
296
297string_newtype!(SeriesName, |v: &str| {
298    validate_simple_name(
299        "series name",
300        v,
301        MAX_SERIES_NAME_LEN,
302        |c| c.is_ascii_lowercase(),
303        is_series_name_char,
304        SERIES_PATTERN,
305    )
306});
307
308string_newtype!(TagName, validate_tag_name);
309
310const MAX_GIT_REF_LEN: usize = 256;
311string_newtype!(GitRef, validate_git_ref);
312
313fn validate_git_ref(value: &str) -> Result<(), NameError> {
314    if value.is_empty() {
315        return Err(NameError::Empty { kind: "git ref" });
316    }
317    if value.len() > MAX_GIT_REF_LEN {
318        return Err(NameError::TooLong {
319            kind: "git ref",
320            len: value.len(),
321            max: MAX_GIT_REF_LEN,
322        });
323    }
324    for ch in value.chars() {
325        if ch.is_ascii_control() || ch.is_whitespace() || ch == '\0' || ch == ':' {
326            return Err(NameError::InvalidCharacter {
327                kind: "git ref",
328                ch,
329                value: value.to_owned(),
330            });
331        }
332    }
333    Ok(())
334}
335
336string_newtype!(TagGlob, validate_tag_glob);
337
338impl TagGlob {
339    /// True if `tag` matches this glob. Supports `*` (zero or more) and `?` (any one).
340    pub fn matches(&self, tag: &TagName) -> bool {
341        glob_match(self.as_str(), tag.as_str())
342    }
343}
344
345fn glob_match(pattern: &str, text: &str) -> bool {
346    let pattern: Vec<char> = pattern.chars().collect();
347    let text: Vec<char> = text.chars().collect();
348    let mut pi = 0usize;
349    let mut ti = 0usize;
350    let mut star_p: Option<usize> = None;
351    let mut star_t: Option<usize> = None;
352    while ti < text.len() {
353        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
354            pi += 1;
355            ti += 1;
356        } else if pi < pattern.len() && pattern[pi] == '*' {
357            star_p = Some(pi);
358            star_t = Some(ti);
359            pi += 1;
360        } else if let (Some(sp), Some(st)) = (star_p, star_t) {
361            pi = sp + 1;
362            star_t = Some(st + 1);
363            ti = st + 1;
364        } else {
365            return false;
366        }
367    }
368    while pi < pattern.len() && pattern[pi] == '*' {
369        pi += 1;
370    }
371    pi == pattern.len()
372}
373
374string_newtype!(ModuleName, |v: &str| {
375    validate_module_name("module name", v, MAX_MODULE_NAME_LEN)
376});
377
378string_newtype!(FunctionName, |v: &str| {
379    validate_erlang_name("function name", v, MAX_FUNCTION_NAME_LEN)
380});
381
382string_newtype!(TypeName, |v: &str| {
383    validate_erlang_name("type name", v, MAX_TYPE_NAME_LEN)
384});
385
386string_newtype!(RecordName, |v: &str| {
387    validate_erlang_name("record name", v, MAX_RECORD_NAME_LEN)
388});
389
390string_newtype!(FieldName, |v: &str| {
391    validate_erlang_name("field name", v, MAX_FIELD_NAME_LEN)
392});
393
394string_newtype!(ApplicationName, |v: &str| {
395    validate_simple_name(
396        "application name",
397        v,
398        MAX_APPLICATION_NAME_LEN,
399        |c| c.is_ascii_lowercase(),
400        is_application_name_char,
401        APPLICATION_PATTERN,
402    )
403});
404
405string_newtype!(BehaviourName, |v: &str| {
406    validate_module_name("behaviour name", v, MAX_BEHAVIOUR_NAME_LEN)
407});
408
409string_newtype!(MacroName, |v: &str| {
410    validate_macro_name("macro name", v, MAX_MACRO_NAME_LEN)
411});
412
413string_newtype!(DependencyName, |v: &str| {
414    validate_dependency_name("dependency name", v, MAX_DEPENDENCY_NAME_LEN)
415});
416
417string_newtype!(DependencyVersion, |v: &str| {
418    validate_dependency_version("dependency version", v, MAX_DEPENDENCY_VERSION_LEN)
419});
420
421fn validate_macro_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
422    validate_simple_name(
423        kind,
424        value,
425        max_len,
426        |c| c.is_ascii_alphabetic() || c == '_',
427        |c| c.is_ascii_alphanumeric() || c == '_',
428        "letter or underscore, then letters digits underscores",
429    )
430}
431
432fn validate_dependency_name(
433    kind: &'static str,
434    value: &str,
435    max_len: usize,
436) -> Result<(), NameError> {
437    validate_simple_name(
438        kind,
439        value,
440        max_len,
441        |c| c.is_ascii_lowercase(),
442        is_application_name_char,
443        "lowercase letter, then lowercase digits underscores",
444    )
445}
446
447fn validate_dependency_version(
448    kind: &'static str,
449    value: &str,
450    max_len: usize,
451) -> Result<(), NameError> {
452    if value.is_empty() {
453        return Err(NameError::Empty { kind });
454    }
455    if value.len() > max_len {
456        return Err(NameError::TooLong {
457            kind,
458            len: value.len(),
459            max: max_len,
460        });
461    }
462    for ch in value.chars() {
463        if ch.is_ascii_control() || ch == '\0' || ch.is_whitespace() {
464            return Err(NameError::InvalidCharacter {
465                kind,
466                ch,
467                value: value.to_owned(),
468            });
469        }
470    }
471    Ok(())
472}
473
474const MAX_RELATIVE_PATH_LEN: usize = 4096;
475
476string_newtype!(RelativePath, validate_relative_path);
477
478fn validate_relative_path(value: &str) -> Result<(), NameError> {
479    if value.is_empty() {
480        return Err(NameError::Empty {
481            kind: "relative path",
482        });
483    }
484    if value.len() > MAX_RELATIVE_PATH_LEN {
485        return Err(NameError::TooLong {
486            kind: "relative path",
487            len: value.len(),
488            max: MAX_RELATIVE_PATH_LEN,
489        });
490    }
491    if value.starts_with('/') {
492        return Err(NameError::Invalid {
493            kind: "relative path",
494            value: value.to_owned(),
495            reason: "absolute paths are not allowed",
496        });
497    }
498    if value.split('/').any(|seg| seg == "..") {
499        return Err(NameError::Invalid {
500            kind: "relative path",
501            value: value.to_owned(),
502            reason: "parent-directory segments are not allowed",
503        });
504    }
505    if value.contains('\0') {
506        return Err(NameError::Invalid {
507            kind: "relative path",
508            value: value.to_owned(),
509            reason: "NUL bytes are not allowed",
510        });
511    }
512    Ok(())
513}
514
515impl RelativePath {
516    /// Build a `RelativePath` from a `gix` byte string.
517    ///
518    /// `gix` reports paths as `BStr` because filesystems may produce
519    /// non-UTF-8 sequences. We hard-error on non-UTF-8 rather than
520    /// using a lossy conversion: silently mangled paths would leak
521    /// into verdict envelopes and downstream consumers would see the
522    /// wrong path.
523    pub fn from_bytes(bytes: &[u8]) -> Result<Self, NameError> {
524        let s = std::str::from_utf8(bytes).map_err(|_| NameError::Invalid {
525            kind: "relative path",
526            value: String::from_utf8_lossy(bytes).into_owned(),
527            reason: "path is not valid UTF-8",
528        })?;
529        Self::new(s.to_owned())
530    }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
534#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
535#[serde(transparent)]
536pub struct Arity(u8);
537
538impl Arity {
539    pub const fn new(value: u8) -> Self {
540        Self(value)
541    }
542
543    pub const fn get(self) -> u8 {
544        self.0
545    }
546}
547
548impl fmt::Display for Arity {
549    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550        write!(f, "{}", self.0)
551    }
552}
553
554impl FromStr for Arity {
555    type Err = NameError;
556
557    fn from_str(s: &str) -> Result<Self, NameError> {
558        let parsed: i64 = s
559            .parse()
560            .map_err(|_| NameError::InvalidArityParse { raw: s.to_owned() })?;
561        if !(0..=255).contains(&parsed) {
562            return Err(NameError::InvalidArity { value: parsed });
563        }
564        Ok(Self(parsed as u8))
565    }
566}
567
568impl TryFrom<usize> for Arity {
569    type Error = NameError;
570
571    fn try_from(value: usize) -> Result<Self, NameError> {
572        u8::try_from(value)
573            .map(Self)
574            .map_err(|_| NameError::InvalidArity {
575                value: i64::try_from(value).unwrap_or(i64::MAX),
576            })
577    }
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
581#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
582#[serde(transparent)]
583pub struct CommitSha(Arc<str>);
584
585impl CommitSha {
586    pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
587        let v = value.into();
588        if v.len() != 40
589            || !v
590                .chars()
591                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
592        {
593            return Err(NameError::InvalidCommitSha { value: v });
594        }
595        Ok(Self(Arc::from(v)))
596    }
597
598    pub fn as_str(&self) -> &str {
599        &self.0
600    }
601
602    /// First 12 hex characters, the conventional `git log --oneline`
603    /// abbreviation length. The validator guarantees 40-char input so
604    /// the slice never panics.
605    #[must_use]
606    pub fn abbreviated(&self) -> &str {
607        &self.0[..12]
608    }
609}
610
611impl fmt::Display for CommitSha {
612    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
613        f.write_str(&self.0)
614    }
615}
616
617impl FromStr for CommitSha {
618    type Err = NameError;
619
620    fn from_str(s: &str) -> Result<Self, NameError> {
621        Self::new(s.to_owned())
622    }
623}
624
625impl AsRef<str> for CommitSha {
626    fn as_ref(&self) -> &str {
627        &self.0
628    }
629}
630
631const MIN_COMMIT_SHA_PREFIX_LEN: usize = 7;
632const MAX_COMMIT_SHA_PREFIX_LEN: usize = 40;
633
634#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
635#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
636#[serde(try_from = "String", into = "String")]
637pub struct CommitShaPrefix(String);
638
639impl TryFrom<String> for CommitShaPrefix {
640    type Error = NameError;
641    fn try_from(value: String) -> Result<Self, NameError> {
642        Self::new(value)
643    }
644}
645
646impl From<CommitShaPrefix> for String {
647    fn from(p: CommitShaPrefix) -> Self {
648        p.0
649    }
650}
651
652impl CommitShaPrefix {
653    pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
654        let raw = value.into();
655        if raw.is_empty() {
656            return Err(NameError::EmptyCommitShaPrefix);
657        }
658        let normalised = raw.to_ascii_lowercase();
659        if let Some((position, ch)) = normalised
660            .chars()
661            .enumerate()
662            .find(|(_, c)| !c.is_ascii_hexdigit())
663        {
664            return Err(NameError::CommitShaPrefixNonHex {
665                value: normalised,
666                ch,
667                position,
668            });
669        }
670        let len = normalised.len();
671        if len < MIN_COMMIT_SHA_PREFIX_LEN {
672            return Err(NameError::CommitShaPrefixTooShort {
673                value: normalised,
674                len,
675            });
676        }
677        if len > MAX_COMMIT_SHA_PREFIX_LEN {
678            return Err(NameError::CommitShaPrefixTooLong {
679                value: normalised,
680                len,
681            });
682        }
683        Ok(Self(normalised))
684    }
685
686    pub fn as_str(&self) -> &str {
687        &self.0
688    }
689
690    pub fn len(&self) -> usize {
691        self.0.len()
692    }
693
694    pub fn is_empty(&self) -> bool {
695        self.0.is_empty()
696    }
697
698    #[must_use]
699    pub fn is_full(&self) -> bool {
700        self.0.len() == MAX_COMMIT_SHA_PREFIX_LEN
701    }
702
703    #[must_use]
704    pub fn as_full_sha(&self) -> Option<CommitSha> {
705        if self.is_full() {
706            CommitSha::new(self.0.clone()).ok()
707        } else {
708            None
709        }
710    }
711}
712
713impl fmt::Display for CommitShaPrefix {
714    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
715        f.write_str(&self.0)
716    }
717}
718
719impl FromStr for CommitShaPrefix {
720    type Err = NameError;
721
722    fn from_str(s: &str) -> Result<Self, NameError> {
723        Self::new(s.to_owned())
724    }
725}
726
727impl AsRef<str> for CommitShaPrefix {
728    fn as_ref(&self) -> &str {
729        &self.0
730    }
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
734#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
735pub struct Mfa {
736    pub module: ModuleName,
737    pub function: FunctionName,
738    pub arity: Arity,
739}
740
741impl Mfa {
742    pub fn new(module: ModuleName, function: FunctionName, arity: Arity) -> Self {
743        Self {
744            module,
745            function,
746            arity,
747        }
748    }
749}
750
751impl fmt::Display for Mfa {
752    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
753        write!(f, "{}:{}/{}", self.module, self.function, self.arity)
754    }
755}
756
757impl FromStr for Mfa {
758    type Err = NameError;
759
760    fn from_str(s: &str) -> Result<Self, NameError> {
761        let (head, arity_part) = s.rsplit_once('/').ok_or_else(|| NameError::InvalidMfa {
762            value: s.to_owned(),
763        })?;
764        let (module_part, function_part) =
765            head.split_once(':').ok_or_else(|| NameError::InvalidMfa {
766                value: s.to_owned(),
767            })?;
768        let arity = arity_part
769            .parse::<Arity>()
770            .map_err(|_| NameError::InvalidMfa {
771                value: s.to_owned(),
772            })?;
773        Ok(Self {
774            module: module_part.parse()?,
775            function: function_part.parse()?,
776            arity,
777        })
778    }
779}