1use 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 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 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
496string_newtype!(MapKey, |v: &str| {
499 validate_erlang_name("map key", v, MAX_MAP_KEY_LEN)
500});
501
502string_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
553string_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
603string_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 #[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 #[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 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 #[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}