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::borrow::Borrow;
9use std::fmt;
10use std::str::FromStr;
11use std::sync::Arc;
12
13use serde::{Deserialize, Serialize};
14
15use crate::errors::NameError;
16use crate::model::release_version::HostSeries;
17
18const MAX_PROJECT_NAME_LEN: usize = 64;
19const MAX_SERIES_NAME_LEN: usize = 64;
20const MAX_TAG_NAME_LEN: usize = 128;
21const MAX_TAG_GLOB_LEN: usize = 128;
22const MAX_MODULE_NAME_LEN: usize = 256;
23const MAX_FUNCTION_NAME_LEN: usize = 256;
24const MAX_TYPE_NAME_LEN: usize = 256;
25const MAX_RECORD_NAME_LEN: usize = 256;
26const MAX_FIELD_NAME_LEN: usize = 256;
27const MAX_APPLICATION_NAME_LEN: usize = 128;
28const MAX_BEHAVIOUR_NAME_LEN: usize = 256;
29const MAX_MACRO_NAME_LEN: usize = 256;
30const MAX_DEPENDENCY_NAME_LEN: usize = 128;
31const MAX_DEPENDENCY_VERSION_LEN: usize = 128;
32
33const APPLICATION_PATTERN: &str = "[a-z][a-z0-9_]{0,127}";
34
35const PROJECT_PATTERN: &str = "[a-z][a-z0-9_-]{0,63}";
36const SERIES_PATTERN: &str = "[a-z][a-z0-9_.-]{0,63}";
37
38fn is_project_name_char(c: char) -> bool {
39    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
40}
41
42fn is_series_name_char(c: char) -> bool {
43    is_project_name_char(c) || c == '.'
44}
45
46fn is_application_name_char(c: char) -> bool {
47    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
48}
49
50fn validate_simple_name(
51    kind: &'static str,
52    value: &str,
53    max_len: usize,
54    first_char_ok: fn(char) -> bool,
55    rest_char_ok: fn(char) -> bool,
56    pattern: &'static str,
57) -> Result<(), NameError> {
58    if value.is_empty() {
59        return Err(NameError::Empty { kind });
60    }
61    if value.len() > max_len {
62        return Err(NameError::TooLong {
63            kind,
64            len: value.len(),
65            max: max_len,
66        });
67    }
68    let mut chars = value.chars();
69    let first = chars.next().expect("non-empty");
70    if !first_char_ok(first) {
71        return Err(NameError::PatternMismatch {
72            kind,
73            value: value.to_owned(),
74            pattern,
75        });
76    }
77    for ch in chars {
78        if !rest_char_ok(ch) {
79            return Err(NameError::InvalidCharacter {
80                kind,
81                ch,
82                value: value.to_owned(),
83            });
84        }
85    }
86    Ok(())
87}
88
89fn validate_tag_glob(value: &str) -> Result<(), NameError> {
90    if value.is_empty() {
91        return Err(NameError::Empty { kind: "tag glob" });
92    }
93    if value.len() > MAX_TAG_GLOB_LEN {
94        return Err(NameError::TooLong {
95            kind: "tag glob",
96            len: value.len(),
97            max: MAX_TAG_GLOB_LEN,
98        });
99    }
100    for ch in value.chars() {
101        if ch.is_ascii_control()
102            || ch.is_whitespace()
103            || ch == '/'
104            || ch == '\\'
105            || ch == '\0'
106            || ch == ':'
107            || ch == '~'
108            || ch == '^'
109            || ch == '['
110        {
111            return Err(NameError::InvalidCharacter {
112                kind: "tag glob",
113                ch,
114                value: value.to_owned(),
115            });
116        }
117    }
118    if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
119        return Err(NameError::PatternMismatch {
120            kind: "tag glob",
121            value: value.to_owned(),
122            pattern: "non-empty, no path separators or git ref-magic",
123        });
124    }
125    Ok(())
126}
127
128fn validate_tag_name(value: &str) -> Result<(), NameError> {
129    if value.is_empty() {
130        return Err(NameError::Empty { kind: "tag" });
131    }
132    if value.len() > MAX_TAG_NAME_LEN {
133        return Err(NameError::TooLong {
134            kind: "tag",
135            len: value.len(),
136            max: MAX_TAG_NAME_LEN,
137        });
138    }
139    for ch in value.chars() {
140        if ch.is_ascii_control()
141            || ch.is_whitespace()
142            || ch == '/'
143            || ch == '\\'
144            || ch == '\0'
145            || ch == ':'
146            || ch == '~'
147            || ch == '^'
148            || ch == '?'
149            || ch == '*'
150            || ch == '['
151        {
152            return Err(NameError::InvalidCharacter {
153                kind: "tag",
154                ch,
155                value: value.to_owned(),
156            });
157        }
158    }
159    if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
160        return Err(NameError::PatternMismatch {
161            kind: "tag",
162            value: value.to_owned(),
163            pattern: "non-empty, no path separators or git ref-magic",
164        });
165    }
166    Ok(())
167}
168
169fn validate_erlang_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
170    if value.is_empty() {
171        return Err(NameError::Empty { kind });
172    }
173    if value.len() > max_len {
174        return Err(NameError::TooLong {
175            kind,
176            len: value.len(),
177            max: max_len,
178        });
179    }
180    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
181        return Ok(());
182    }
183    let first = value.chars().next().expect("non-empty");
184    if !(first.is_ascii_lowercase() || first == '_') {
185        return Err(NameError::PatternMismatch {
186            kind,
187            value: value.to_owned(),
188            pattern: "lowercase-led atom or single-quoted",
189        });
190    }
191    let last = value.chars().last().expect("non-empty");
192    let drop_trailing_punct = if matches!(last, '?' | '!') {
193        value.len() - last.len_utf8()
194    } else {
195        value.len()
196    };
197    for ch in value[..drop_trailing_punct].chars() {
198        if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@') {
199            return Err(NameError::InvalidCharacter {
200                kind,
201                ch,
202                value: value.to_owned(),
203            });
204        }
205    }
206    Ok(())
207}
208
209fn validate_module_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
210    if value.is_empty() {
211        return Err(NameError::Empty { kind });
212    }
213    if value.len() > max_len {
214        return Err(NameError::TooLong {
215            kind,
216            len: value.len(),
217            max: max_len,
218        });
219    }
220    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
221        return Ok(());
222    }
223    let first = value.chars().next().expect("non-empty");
224    if !(first.is_ascii_alphabetic() || first == '_') {
225        return Err(NameError::PatternMismatch {
226            kind,
227            value: value.to_owned(),
228            pattern: "Erlang atom or Elixir module name",
229        });
230    }
231    for ch in value.chars() {
232        if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@' || ch == '.') {
233            return Err(NameError::InvalidCharacter {
234                kind,
235                ch,
236                value: value.to_owned(),
237            });
238        }
239    }
240    Ok(())
241}
242
243macro_rules! string_newtype {
244    ($name:ident, $validate:expr) => {
245        #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
246        #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
247        #[serde(transparent)]
248        pub struct $name(Arc<str>);
249
250        impl $name {
251            pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
252                let v = value.into();
253                $validate(&v)?;
254                Ok(Self(Arc::from(v)))
255            }
256
257            pub fn as_str(&self) -> &str {
258                &self.0
259            }
260
261            pub fn into_inner(self) -> String {
262                self.0.to_string()
263            }
264        }
265
266        impl AsRef<str> for $name {
267            fn as_ref(&self) -> &str {
268                &self.0
269            }
270        }
271
272        impl PartialEq<str> for $name {
273            fn eq(&self, other: &str) -> bool {
274                &*self.0 == other
275            }
276        }
277
278        impl PartialEq<&str> for $name {
279            fn eq(&self, other: &&str) -> bool {
280                &*self.0 == *other
281            }
282        }
283
284        // lets a `BTreeSet<$name>` answer `contains` for a scanned `&str`
285        // without allocating a candidate first
286        impl Borrow<str> for $name {
287            fn borrow(&self) -> &str {
288                &self.0
289            }
290        }
291
292        impl fmt::Display for $name {
293            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294                f.write_str(&self.0)
295            }
296        }
297
298        impl FromStr for $name {
299            type Err = NameError;
300
301            fn from_str(s: &str) -> Result<Self, NameError> {
302                Self::new(s.to_owned())
303            }
304        }
305    };
306}
307
308macro_rules! vocabulary {
309    (
310        $(#[$enum_meta:meta])*
311        $vis:vis enum $name:ident : $noun:literal {
312            $($(#[$variant_meta:meta])* $variant:ident => $label:literal),+ $(,)?
313        }
314    ) => {
315        $(#[$enum_meta])*
316        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317        #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
318        $vis enum $name {
319            $($(#[$variant_meta])* #[serde(rename = $label)] $variant),+
320        }
321
322        impl $name {
323            pub const ALL: &'static [Self] = &[$(Self::$variant),+];
324
325            #[must_use]
326            pub const fn label(self) -> &'static str {
327                match self {
328                    $(Self::$variant => $label),+
329                }
330            }
331        }
332
333        impl std::fmt::Display for $name {
334            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335                f.write_str(self.label())
336            }
337        }
338
339        impl std::str::FromStr for $name {
340            type Err = crate::errors::UnknownLabel;
341
342            fn from_str(s: &str) -> Result<Self, Self::Err> {
343                match s {
344                    $($label => Ok(Self::$variant),)+
345                    other => Err(crate::errors::UnknownLabel::new(
346                        $noun,
347                        other,
348                        Self::ALL.iter().map(|v| v.label()),
349                    )),
350                }
351            }
352        }
353    };
354}
355
356pub(crate) use vocabulary;
357
358string_newtype!(ProjectName, |v: &str| {
359    validate_simple_name(
360        "project name",
361        v,
362        MAX_PROJECT_NAME_LEN,
363        |c| c.is_ascii_lowercase(),
364        is_project_name_char,
365        PROJECT_PATTERN,
366    )
367});
368
369string_newtype!(SeriesName, |v: &str| {
370    validate_simple_name(
371        "series name",
372        v,
373        MAX_SERIES_NAME_LEN,
374        |c| c.is_ascii_lowercase(),
375        is_series_name_char,
376        SERIES_PATTERN,
377    )
378});
379
380string_newtype!(TagName, validate_tag_name);
381
382const MAX_GIT_REF_LEN: usize = 256;
383string_newtype!(GitRef, validate_git_ref);
384
385fn validate_git_ref(value: &str) -> Result<(), NameError> {
386    if value.is_empty() {
387        return Err(NameError::Empty { kind: "git ref" });
388    }
389    if value.len() > MAX_GIT_REF_LEN {
390        return Err(NameError::TooLong {
391            kind: "git ref",
392            len: value.len(),
393            max: MAX_GIT_REF_LEN,
394        });
395    }
396    for ch in value.chars() {
397        if ch.is_ascii_control() || ch.is_whitespace() || ch == '\0' || ch == ':' {
398            return Err(NameError::InvalidCharacter {
399                kind: "git ref",
400                ch,
401                value: value.to_owned(),
402            });
403        }
404    }
405    Ok(())
406}
407
408string_newtype!(TagGlob, validate_tag_glob);
409
410impl TagGlob {
411    /// True if `tag` matches this glob. Supports `*` (zero or more) and `?` (any one).
412    pub fn matches(&self, tag: &TagName) -> bool {
413        glob_match(self.as_str(), tag.as_str())
414    }
415}
416
417fn glob_match(pattern: &str, text: &str) -> bool {
418    let pattern: Vec<char> = pattern.chars().collect();
419    let text: Vec<char> = text.chars().collect();
420    let mut pi = 0usize;
421    let mut ti = 0usize;
422    let mut star_p: Option<usize> = None;
423    let mut star_t: Option<usize> = None;
424    while ti < text.len() {
425        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
426            pi += 1;
427            ti += 1;
428        } else if pi < pattern.len() && pattern[pi] == '*' {
429            star_p = Some(pi);
430            star_t = Some(ti);
431            pi += 1;
432        } else if let (Some(sp), Some(st)) = (star_p, star_t) {
433            pi = sp + 1;
434            star_t = Some(st + 1);
435            ti = st + 1;
436        } else {
437            return false;
438        }
439    }
440    while pi < pattern.len() && pattern[pi] == '*' {
441        pi += 1;
442    }
443    pi == pattern.len()
444}
445
446string_newtype!(ModuleName, |v: &str| {
447    validate_module_name("module name", v, MAX_MODULE_NAME_LEN)
448});
449
450string_newtype!(FunctionName, |v: &str| {
451    validate_erlang_name("function name", v, MAX_FUNCTION_NAME_LEN)
452});
453
454string_newtype!(TypeName, |v: &str| {
455    validate_erlang_name("type name", v, MAX_TYPE_NAME_LEN)
456});
457
458string_newtype!(RecordName, |v: &str| {
459    validate_erlang_name("record name", v, MAX_RECORD_NAME_LEN)
460});
461
462string_newtype!(FieldName, |v: &str| {
463    validate_erlang_name("field name", v, MAX_FIELD_NAME_LEN)
464});
465
466string_newtype!(ApplicationName, |v: &str| {
467    validate_simple_name(
468        "application name",
469        v,
470        MAX_APPLICATION_NAME_LEN,
471        |c| c.is_ascii_lowercase(),
472        is_application_name_char,
473        APPLICATION_PATTERN,
474    )
475});
476
477string_newtype!(BehaviourName, |v: &str| {
478    validate_module_name("behaviour name", v, MAX_BEHAVIOUR_NAME_LEN)
479});
480
481string_newtype!(MacroName, |v: &str| {
482    validate_macro_name("macro name", v, MAX_MACRO_NAME_LEN)
483});
484
485string_newtype!(DependencyName, |v: &str| {
486    validate_dependency_name("dependency name", v, MAX_DEPENDENCY_NAME_LEN)
487});
488
489string_newtype!(DependencyVersion, |v: &str| {
490    validate_dependency_version("dependency version", v, MAX_DEPENDENCY_VERSION_LEN)
491});
492
493const MAX_MAP_KEY_LEN: usize = 256;
494const MAX_SCHEMA_FEATURE_LEN: usize = 128;
495
496// A literal atom key found at the top level of a family-declared
497// option map, either bare or quoted.
498string_newtype!(MapKey, |v: &str| {
499    validate_erlang_name("map key", v, MAX_MAP_KEY_LEN)
500});
501
502// A cuttlefish mapping attribute name gated on a minimum schema
503// version (`alias`, and future floor-table entries).
504string_newtype!(SchemaFeature, |v: &str| {
505    validate_simple_name(
506        "schema feature",
507        v,
508        MAX_SCHEMA_FEATURE_LEN,
509        |c| c.is_ascii_lowercase(),
510        is_application_name_char,
511        "lowercase letter, then lowercase digits underscores",
512    )
513});
514
515fn validate_macro_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
516    validate_simple_name(
517        kind,
518        value,
519        max_len,
520        |c| c.is_ascii_alphabetic() || c == '_',
521        |c| c.is_ascii_alphanumeric() || c == '_',
522        "letter or underscore, then letters digits underscores",
523    )
524}
525
526const MAX_CONF_KEY_LEN: usize = 256;
527const MAX_VALIDATOR_NAME_LEN: usize = 128;
528const MAX_PARTIAL_NAME_LEN: usize = 128;
529const MAX_MAKE_VARIABLE_NAME_LEN: usize = 128;
530
531string_newtype!(ConfKey, |v: &str| {
532    validate_simple_name(
533        "configuration key",
534        v,
535        MAX_CONF_KEY_LEN,
536        |c| c.is_ascii_alphanumeric() || c == '_' || c == '$',
537        |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.' | '-' | '@' | '*'),
538        "letters digits and _ $ . - @ *, not starting with a separator",
539    )
540});
541
542string_newtype!(ValidatorName, |v: &str| {
543    validate_simple_name(
544        "validator name",
545        v,
546        MAX_VALIDATOR_NAME_LEN,
547        |c| c.is_ascii_alphanumeric() || c == '_',
548        |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-' | '@'),
549        "letters digits and _ . - @, not starting with a separator",
550    )
551});
552
553// `cuttlefish_partial` resolves `{App, Name}` to `<Name>.partial`, so a name has no path separator or extension.
554string_newtype!(PartialName, |v: &str| {
555    validate_simple_name(
556        "partial name",
557        v,
558        MAX_PARTIAL_NAME_LEN,
559        |c| c.is_ascii_alphanumeric() || c == '_',
560        |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'),
561        "letters digits _ -, without '/' or '.'",
562    )
563});
564
565string_newtype!(MakeVariableName, |v: &str| {
566    validate_simple_name(
567        "make variable name",
568        v,
569        MAX_MAKE_VARIABLE_NAME_LEN,
570        |c| c.is_ascii_alphabetic() || c == '_',
571        |c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'),
572        "letter or underscore, then letters digits _ . -",
573    )
574});
575
576string_newtype!(HostFactsDigest, |v: &str| {
577    validate_lower_hex(
578        "host facts digest",
579        v,
580        BLAKE3_HEX_LEN,
581        "expected 64 lowercase hex characters",
582    )
583});
584
585string_newtype!(ExtractionSettingsDigest, |v: &str| {
586    validate_lower_hex(
587        "extraction settings digest",
588        v,
589        BLAKE3_HEX_LEN,
590        "expected 64 lowercase hex characters",
591    )
592});
593
594string_newtype!(StoreDigest, |v: &str| {
595    validate_lower_hex(
596        "store digest",
597        v,
598        BLAKE3_HEX_LEN,
599        "expected 64 lowercase hex characters",
600    )
601});
602
603// The text of a `-spec` as a snapshot records it.
604string_newtype!(SpecText, |v: &str| {
605    if v.trim().is_empty() {
606        return Err(NameError::Empty { kind: "spec text" });
607    }
608    Ok(())
609});
610
611const BLAKE3_HEX_LEN: usize = 64;
612
613fn validate_lower_hex(
614    kind: &'static str,
615    value: &str,
616    len: usize,
617    reason: &'static str,
618) -> Result<(), NameError> {
619    if value.len() != len || !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
620        return Err(NameError::Invalid {
621            kind,
622            value: value.to_owned(),
623            reason,
624        });
625    }
626    Ok(())
627}
628
629impl HostFactsDigest {
630    #[must_use]
631    pub fn from_digest(digest: [u8; 32]) -> Self {
632        Self(Arc::from(lower_hex(&digest)))
633    }
634}
635
636impl ExtractionSettingsDigest {
637    #[must_use]
638    pub fn from_digest(digest: [u8; 32]) -> Self {
639        Self(Arc::from(lower_hex(&digest)))
640    }
641}
642
643impl StoreDigest {
644    #[must_use]
645    pub fn from_digest(digest: [u8; 32]) -> Self {
646        Self(Arc::from(lower_hex(&digest)))
647    }
648}
649
650fn lower_hex(bytes: &[u8]) -> String {
651    const HEX: &[u8; 16] = b"0123456789abcdef";
652    let mut hex = String::with_capacity(bytes.len() * 2);
653    for byte in bytes {
654        hex.push(HEX[usize::from(byte >> 4)] as char);
655        hex.push(HEX[usize::from(byte & 0x0f)] as char);
656    }
657    hex
658}
659
660string_newtype!(HostName, |v: &str| {
661    validate_simple_name(
662        "host name",
663        v,
664        MAX_PROJECT_NAME_LEN,
665        |c| c.is_ascii_lowercase(),
666        is_project_name_char,
667        PROJECT_PATTERN,
668    )
669});
670
671const MAX_EXTRACTOR_VERSION_LEN: usize = 16;
672
673string_newtype!(ExtractorVersion, |v: &str| {
674    if v.is_empty() {
675        return Err(NameError::Empty {
676            kind: "extractor version",
677        });
678    }
679    if v.len() > MAX_EXTRACTOR_VERSION_LEN {
680        return Err(NameError::TooLong {
681            kind: "extractor version",
682            len: v.len(),
683            max: MAX_EXTRACTOR_VERSION_LEN,
684        });
685    }
686    match v
687        .chars()
688        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_')))
689    {
690        Some(ch) => Err(NameError::InvalidCharacter {
691            kind: "extractor version",
692            ch,
693            value: v.to_owned(),
694        }),
695        None => Ok(()),
696    }
697});
698
699const SERIES_PLACEHOLDER: &str = "{series}";
700
701string_newtype!(SeriesTagPattern, validate_series_tag_pattern);
702
703fn validate_series_tag_pattern(value: &str) -> Result<(), NameError> {
704    let invalid = |reason: &'static str| NameError::Invalid {
705        kind: "series tag pattern",
706        value: value.to_owned(),
707        reason,
708    };
709    if value.matches(SERIES_PLACEHOLDER).count() != 1 {
710        return Err(invalid("expected exactly one {series} placeholder"));
711    }
712    let without = value.replacen(SERIES_PLACEHOLDER, "", 1);
713    if without.contains(['{', '}']) {
714        return Err(invalid(
715            "braces are only allowed in the {series} placeholder",
716        ));
717    }
718    validate_tag_glob(&value.replacen(SERIES_PLACEHOLDER, "0.0", 1))
719}
720
721impl SeriesTagPattern {
722    #[must_use]
723    pub fn for_series(&self, series: HostSeries) -> TagGlob {
724        let glob = self.0.replacen(SERIES_PLACEHOLDER, &series.to_string(), 1);
725        TagGlob(Arc::from(glob))
726    }
727}
728
729const PLAN_CACHE_KEY_PREFIX: &str = "jigsaw-";
730const PLAN_DIGEST_BYTES: usize = 8;
731const PLAN_DIGEST_HEX_LEN: usize = PLAN_DIGEST_BYTES * 2;
732const MAX_PLAN_CACHE_KEY_LEN: usize = 256;
733
734string_newtype!(PlanCacheKey, validate_plan_cache_key);
735
736fn validate_plan_cache_key(value: &str) -> Result<(), NameError> {
737    let invalid = |reason: &'static str| NameError::Invalid {
738        kind: "plan cache key",
739        value: value.to_owned(),
740        reason,
741    };
742    if value.len() > MAX_PLAN_CACHE_KEY_LEN {
743        return Err(NameError::TooLong {
744            kind: "plan cache key",
745            len: value.len(),
746            max: MAX_PLAN_CACHE_KEY_LEN,
747        });
748    }
749    if !value.starts_with(PLAN_CACHE_KEY_PREFIX) {
750        return Err(invalid("expected the jigsaw- prefix"));
751    }
752    if !value
753        .chars()
754        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.' | '_'))
755    {
756        return Err(invalid("expected lowercase letters, digits and - . _"));
757    }
758    let Some(split) = value.len().checked_sub(PLAN_DIGEST_HEX_LEN) else {
759        return Err(invalid("expected a 16 character digest suffix"));
760    };
761    let (head, digest) = value.split_at(split);
762    if !head.ends_with('-') || !digest.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
763        return Err(invalid(
764            "expected a dash and 16 lowercase hex characters at the end",
765        ));
766    }
767    Ok(())
768}
769
770impl PlanCacheKey {
771    /// `jigsaw-<host>-<series>-f<format>-e<extractor>-<digest>`
772    #[must_use]
773    pub fn compose(
774        host: &HostName,
775        series: HostSeries,
776        format_version: u32,
777        extractor: &ExtractorVersion,
778        digest: [u8; PLAN_DIGEST_BYTES],
779    ) -> Self {
780        let key = format!(
781            "{PLAN_CACHE_KEY_PREFIX}{host}-{series}-f{format_version}-e{extractor}-{}",
782            lower_hex(&digest)
783        );
784        Self(Arc::from(key))
785    }
786
787    /// The key up to and including the dash before the digest, for
788    /// `actions/cache` restore keys.
789    #[must_use]
790    pub fn restore_prefix(&self) -> &str {
791        &self.0[..self.0.len() - PLAN_DIGEST_HEX_LEN]
792    }
793}
794
795fn validate_dependency_name(
796    kind: &'static str,
797    value: &str,
798    max_len: usize,
799) -> Result<(), NameError> {
800    validate_simple_name(
801        kind,
802        value,
803        max_len,
804        |c| c.is_ascii_lowercase(),
805        is_application_name_char,
806        "lowercase letter, then lowercase digits underscores",
807    )
808}
809
810fn validate_dependency_version(
811    kind: &'static str,
812    value: &str,
813    max_len: usize,
814) -> Result<(), NameError> {
815    if value.is_empty() {
816        return Err(NameError::Empty { kind });
817    }
818    if value.len() > max_len {
819        return Err(NameError::TooLong {
820            kind,
821            len: value.len(),
822            max: max_len,
823        });
824    }
825    for ch in value.chars() {
826        if ch.is_ascii_control() || ch == '\0' || ch.is_whitespace() {
827            return Err(NameError::InvalidCharacter {
828                kind,
829                ch,
830                value: value.to_owned(),
831            });
832        }
833    }
834    Ok(())
835}
836
837const MAX_RELATIVE_PATH_LEN: usize = 4096;
838
839string_newtype!(RelativePath, validate_relative_path);
840
841fn validate_relative_path(value: &str) -> Result<(), NameError> {
842    if value.is_empty() {
843        return Err(NameError::Empty {
844            kind: "relative path",
845        });
846    }
847    if value.len() > MAX_RELATIVE_PATH_LEN {
848        return Err(NameError::TooLong {
849            kind: "relative path",
850            len: value.len(),
851            max: MAX_RELATIVE_PATH_LEN,
852        });
853    }
854    if value.starts_with('/') {
855        return Err(NameError::Invalid {
856            kind: "relative path",
857            value: value.to_owned(),
858            reason: "absolute paths are not allowed",
859        });
860    }
861    if value.split('/').any(|seg| seg == "..") {
862        return Err(NameError::Invalid {
863            kind: "relative path",
864            value: value.to_owned(),
865            reason: "parent-directory segments are not allowed",
866        });
867    }
868    if value.contains('\0') {
869        return Err(NameError::Invalid {
870            kind: "relative path",
871            value: value.to_owned(),
872            reason: "NUL bytes are not allowed",
873        });
874    }
875    Ok(())
876}
877
878impl RelativePath {
879    /// Build a `RelativePath` from a `gix` byte string.
880    ///
881    /// `gix` reports paths as `BStr` because filesystems may produce
882    /// non-UTF-8 sequences. We hard-error on non-UTF-8 rather than
883    /// using a lossy conversion: silently mangled paths would leak
884    /// into verdict envelopes and downstream consumers would see the
885    /// wrong path.
886    pub fn from_bytes(bytes: &[u8]) -> Result<Self, NameError> {
887        let s = std::str::from_utf8(bytes).map_err(|_| NameError::Invalid {
888            kind: "relative path",
889            value: String::from_utf8_lossy(bytes).into_owned(),
890            reason: "path is not valid UTF-8",
891        })?;
892        Self::new(s.to_owned())
893    }
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
897#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
898#[serde(transparent)]
899pub struct Arity(u8);
900
901impl Arity {
902    pub const fn new(value: u8) -> Self {
903        Self(value)
904    }
905
906    pub const fn get(self) -> u8 {
907        self.0
908    }
909}
910
911impl fmt::Display for Arity {
912    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
913        write!(f, "{}", self.0)
914    }
915}
916
917impl FromStr for Arity {
918    type Err = NameError;
919
920    fn from_str(s: &str) -> Result<Self, NameError> {
921        let parsed: i64 = s
922            .parse()
923            .map_err(|_| NameError::InvalidArityParse { raw: s.to_owned() })?;
924        if !(0..=255).contains(&parsed) {
925            return Err(NameError::InvalidArity { value: parsed });
926        }
927        Ok(Self(parsed as u8))
928    }
929}
930
931impl TryFrom<usize> for Arity {
932    type Error = NameError;
933
934    fn try_from(value: usize) -> Result<Self, NameError> {
935        u8::try_from(value)
936            .map(Self)
937            .map_err(|_| NameError::InvalidArity {
938                value: i64::try_from(value).unwrap_or(i64::MAX),
939            })
940    }
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
945#[serde(transparent)]
946pub struct CommitSha(Arc<str>);
947
948impl CommitSha {
949    pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
950        let v = value.into();
951        if v.len() != 40
952            || !v
953                .chars()
954                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
955        {
956            return Err(NameError::InvalidCommitSha { value: v });
957        }
958        Ok(Self(Arc::from(v)))
959    }
960
961    pub fn as_str(&self) -> &str {
962        &self.0
963    }
964
965    /// First 12 hex characters, the conventional `git log --oneline`
966    /// abbreviation length. The validator guarantees 40-char input so
967    /// the slice never panics.
968    #[must_use]
969    pub fn abbreviated(&self) -> &str {
970        &self.0[..12]
971    }
972}
973
974impl fmt::Display for CommitSha {
975    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
976        f.write_str(&self.0)
977    }
978}
979
980impl FromStr for CommitSha {
981    type Err = NameError;
982
983    fn from_str(s: &str) -> Result<Self, NameError> {
984        Self::new(s.to_owned())
985    }
986}
987
988impl AsRef<str> for CommitSha {
989    fn as_ref(&self) -> &str {
990        &self.0
991    }
992}
993
994const MIN_COMMIT_SHA_PREFIX_LEN: usize = 7;
995const MAX_COMMIT_SHA_PREFIX_LEN: usize = 40;
996
997#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
998#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
999#[serde(try_from = "String", into = "String")]
1000pub struct CommitShaPrefix(String);
1001
1002impl TryFrom<String> for CommitShaPrefix {
1003    type Error = NameError;
1004    fn try_from(value: String) -> Result<Self, NameError> {
1005        Self::new(value)
1006    }
1007}
1008
1009impl From<CommitShaPrefix> for String {
1010    fn from(p: CommitShaPrefix) -> Self {
1011        p.0
1012    }
1013}
1014
1015impl CommitShaPrefix {
1016    pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
1017        let raw = value.into();
1018        if raw.is_empty() {
1019            return Err(NameError::EmptyCommitShaPrefix);
1020        }
1021        let normalised = raw.to_ascii_lowercase();
1022        if let Some((position, ch)) = normalised
1023            .chars()
1024            .enumerate()
1025            .find(|(_, c)| !c.is_ascii_hexdigit())
1026        {
1027            return Err(NameError::CommitShaPrefixNonHex {
1028                value: normalised,
1029                ch,
1030                position,
1031            });
1032        }
1033        let len = normalised.len();
1034        if len < MIN_COMMIT_SHA_PREFIX_LEN {
1035            return Err(NameError::CommitShaPrefixTooShort {
1036                value: normalised,
1037                len,
1038            });
1039        }
1040        if len > MAX_COMMIT_SHA_PREFIX_LEN {
1041            return Err(NameError::CommitShaPrefixTooLong {
1042                value: normalised,
1043                len,
1044            });
1045        }
1046        Ok(Self(normalised))
1047    }
1048
1049    pub fn as_str(&self) -> &str {
1050        &self.0
1051    }
1052
1053    pub fn len(&self) -> usize {
1054        self.0.len()
1055    }
1056
1057    pub fn is_empty(&self) -> bool {
1058        self.0.is_empty()
1059    }
1060
1061    #[must_use]
1062    pub fn is_full(&self) -> bool {
1063        self.0.len() == MAX_COMMIT_SHA_PREFIX_LEN
1064    }
1065
1066    #[must_use]
1067    pub fn as_full_sha(&self) -> Option<CommitSha> {
1068        if self.is_full() {
1069            CommitSha::new(self.0.clone()).ok()
1070        } else {
1071            None
1072        }
1073    }
1074}
1075
1076impl fmt::Display for CommitShaPrefix {
1077    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1078        f.write_str(&self.0)
1079    }
1080}
1081
1082impl FromStr for CommitShaPrefix {
1083    type Err = NameError;
1084
1085    fn from_str(s: &str) -> Result<Self, NameError> {
1086        Self::new(s.to_owned())
1087    }
1088}
1089
1090impl AsRef<str> for CommitShaPrefix {
1091    fn as_ref(&self) -> &str {
1092        &self.0
1093    }
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
1097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1098pub struct Mfa {
1099    pub module: ModuleName,
1100    pub function: FunctionName,
1101    pub arity: Arity,
1102}
1103
1104impl Mfa {
1105    pub fn new(module: ModuleName, function: FunctionName, arity: Arity) -> Self {
1106        Self {
1107            module,
1108            function,
1109            arity,
1110        }
1111    }
1112}
1113
1114impl fmt::Display for Mfa {
1115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1116        write!(f, "{}:{}/{}", self.module, self.function, self.arity)
1117    }
1118}
1119
1120impl FromStr for Mfa {
1121    type Err = NameError;
1122
1123    fn from_str(s: &str) -> Result<Self, NameError> {
1124        let (head, arity_part) = s.rsplit_once('/').ok_or_else(|| NameError::InvalidMfa {
1125            value: s.to_owned(),
1126        })?;
1127        let (module_part, function_part) =
1128            head.split_once(':').ok_or_else(|| NameError::InvalidMfa {
1129                value: s.to_owned(),
1130            })?;
1131        let arity = arity_part
1132            .parse::<Arity>()
1133            .map_err(|_| NameError::InvalidMfa {
1134                value: s.to_owned(),
1135            })?;
1136        Ok(Self {
1137            module: module_part.parse()?,
1138            function: function_part.parse()?,
1139            arity,
1140        })
1141    }
1142}