1use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum ToolId {
18 Fixed(String),
20 Dynamic { namespace: String, subject: String },
22}
23
24impl ToolId {
25 pub fn parse(value: &str) -> Result<Self> {
27 let value = value.trim();
28 if let Some((namespace, subject)) = value.split_once(':') {
29 Self::dynamic(namespace, subject)
30 } else {
31 Self::fixed(value)
32 }
33 }
34
35 pub fn fixed(value: impl AsRef<str>) -> Result<Self> {
36 Ok(Self::Fixed(canonical_fixed_name(value.as_ref())?))
37 }
38
39 pub fn dynamic(namespace: &str, subject: &str) -> Result<Self> {
40 let namespace = canonical_namespace(namespace)?;
41 let schema = namespace_schema(&namespace)
42 .ok_or_else(|| Error::UnknownBackend(format!("{namespace}:{}", subject.trim())))?;
43 let subject = schema.canonicalize_subject(subject)?;
44 Ok(Self::Dynamic { namespace, subject })
45 }
46
47 pub fn is_dynamic(&self) -> bool {
48 matches!(self, Self::Dynamic { .. })
49 }
50
51 pub fn namespace(&self) -> Option<&str> {
52 match self {
53 Self::Fixed(_) => None,
54 Self::Dynamic { namespace, .. } => Some(namespace),
55 }
56 }
57
58 pub fn subject(&self) -> &str {
60 match self {
61 Self::Fixed(name) => name,
62 Self::Dynamic { subject, .. } => subject,
63 }
64 }
65
66 pub fn schema(&self) -> Option<&'static NamespaceSchema> {
67 self.namespace().and_then(namespace_schema)
68 }
69}
70
71impl fmt::Display for ToolId {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::Fixed(name) => formatter.write_str(name),
75 Self::Dynamic { namespace, subject } => {
76 write!(formatter, "{namespace}:{subject}")
77 }
78 }
79 }
80}
81
82impl std::str::FromStr for ToolId {
83 type Err = Error;
84
85 fn from_str(value: &str) -> Result<Self> {
86 Self::parse(value)
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93pub enum InstallScope {
94 Isolated,
95 Global,
96 ProjectManaged,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "kebab-case")]
102pub enum InstallDependencyKind {
103 Runtime,
104 Installer,
105 Tool,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct InstallDependency {
112 pub kind: InstallDependencyKind,
113 pub id: String,
114 pub version: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub identity: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct InstallIdentity {
129 pub tool: String,
130 pub version: String,
131 pub platform: String,
132 pub scope: InstallScope,
133 #[serde(default)]
134 pub material_options: BTreeMap<String, String>,
135 #[serde(default)]
136 pub dependencies: Vec<InstallDependency>,
137 #[serde(default)]
138 pub materials: BTreeMap<String, String>,
139 pub install_id: String,
140}
141
142impl InstallIdentity {
143 #[allow(clippy::too_many_arguments)]
144 pub fn new(
145 tool: impl AsRef<str>,
146 version: impl Into<String>,
147 platform: impl Into<String>,
148 scope: InstallScope,
149 options: &BTreeMap<String, String>,
150 mut dependencies: Vec<InstallDependency>,
151 materials: BTreeMap<String, String>,
152 ) -> Result<Self> {
153 let tool = ToolId::parse(tool.as_ref())?;
154 if !tool.is_dynamic() {
155 return Err(Error::config(format!(
156 "install identity requires a dynamic tool id: `{tool}`"
157 )));
158 }
159 let material_options = dynamic_identity_options(&tool, options)?.into_map();
160 let version = version.into();
161 let platform = platform.into();
162 validate_identity_text("version", &version)?;
163 validate_identity_text("platform", &platform)?;
164 validate_dependencies(&mut dependencies)?;
165 validate_materials(&materials)?;
166 let mut identity = Self {
167 tool: tool.to_string(),
168 version,
169 platform,
170 scope,
171 material_options,
172 dependencies,
173 materials,
174 install_id: String::new(),
175 };
176 identity.install_id = crate::backend::dynamic::install_identity_fingerprint(&identity)?;
177 Ok(identity)
178 }
179
180 pub fn validate(&self) -> Result<()> {
182 let tool = ToolId::parse(&self.tool)?;
183 if !tool.is_dynamic() || tool.to_string() != self.tool {
184 return Err(Error::config(
185 "install identity contains a non-canonical dynamic tool id",
186 ));
187 }
188 validate_identity_text("version", &self.version)?;
189 validate_identity_text("platform", &self.platform)?;
190 validate_canonical_identity_options(&tool, &self.material_options)?;
191 let mut dependencies = self.dependencies.clone();
192 validate_dependencies(&mut dependencies)?;
193 if dependencies != self.dependencies {
194 return Err(Error::config(
195 "install identity dependencies are not canonical",
196 ));
197 }
198 validate_materials(&self.materials)?;
199 let expected = crate::backend::dynamic::install_identity_fingerprint(self)?;
200 if self.install_id != expected {
201 return Err(Error::config(
202 "dynamic install identity fingerprint mismatch",
203 ));
204 }
205 Ok(())
206 }
207}
208
209fn validate_identity_text(label: &str, value: &str) -> Result<()> {
210 if value.trim().is_empty() || value.trim() != value || value.chars().any(char::is_control) {
211 return Err(Error::config(format!(
212 "install identity {label} must be non-empty canonical text"
213 )));
214 }
215 Ok(())
216}
217
218fn validate_dependencies(dependencies: &mut Vec<InstallDependency>) -> Result<()> {
219 for dependency in dependencies.iter_mut() {
220 validate_identity_text("dependency id", &dependency.id)?;
221 dependency.id = ToolId::parse(&dependency.id)?.to_string();
222 validate_identity_text("dependency version", &dependency.version)?;
223 if let Some(identity) = &dependency.identity {
224 validate_identity_text("dependency identity", identity)?;
225 }
226 }
227 dependencies.sort();
228 dependencies.dedup();
229 Ok(())
230}
231
232fn validate_materials(materials: &BTreeMap<String, String>) -> Result<()> {
233 for (key, value) in materials {
234 validate_identity_text("material key", key)?;
235 validate_identity_text("material value", value)?;
236 }
237 Ok(())
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
242pub enum OptionEffect {
243 Resolution,
244 Artifact,
245 Layout,
246 Execution,
247 Secret,
248}
249
250#[derive(Clone, Copy)]
252pub struct OptionDefinition {
253 pub name: &'static str,
254 pub canonical_name: &'static str,
255 pub effect: OptionEffect,
256 pub identity: bool,
258 canonicalizer: fn(&str) -> Result<Option<String>>,
259}
260
261impl fmt::Debug for OptionDefinition {
262 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
263 formatter
264 .debug_struct("OptionDefinition")
265 .field("name", &self.name)
266 .field("canonical_name", &self.canonical_name)
267 .field("effect", &self.effect)
268 .field("identity", &self.identity)
269 .finish_non_exhaustive()
270 }
271}
272
273type OptionSetValidator = fn(&ToolId, &BTreeMap<String, String>, &CanonicalOptions) -> Result<()>;
274type SelectorValidator = fn(&ToolId, Option<&str>) -> Result<()>;
275
276#[derive(Debug)]
278pub struct OptionSchema {
279 definitions: &'static [OptionDefinition],
280 validator: OptionSetValidator,
281}
282
283impl OptionSchema {
284 pub fn definitions(&self) -> &'static [OptionDefinition] {
285 self.definitions
286 }
287
288 pub fn definition(&self, name: &str) -> Option<&'static OptionDefinition> {
289 self.definitions
290 .iter()
291 .find(|definition| definition.name == name)
292 }
293
294 pub fn canonicalize(
297 &self,
298 id: &ToolId,
299 options: &BTreeMap<String, String>,
300 ) -> Result<CanonicalOptions> {
301 let mut canonical = BTreeMap::new();
302 for (raw_name, raw_value) in options {
303 if raw_name.starts_with("__osdk_") {
304 continue;
305 }
306 let definition = self.definition(raw_name).ok_or_else(|| {
307 Error::config(format!(
308 "unsupported option `{raw_name}` for dynamic backend `{id}`"
309 ))
310 })?;
311 let Some(value) = (definition.canonicalizer)(raw_value)? else {
312 continue;
313 };
314 if canonical
315 .insert(definition.canonical_name.to_string(), value)
316 .is_some()
317 {
318 return Err(Error::config(format!(
319 "options `{raw_name}` and `{}` are mutually exclusive",
320 definition.canonical_name
321 )));
322 }
323 }
324 let canonical = CanonicalOptions(canonical);
325 (self.validator)(id, options, &canonical)?;
326 Ok(canonical)
327 }
328
329 pub fn validate_canonical_identity(
332 &self,
333 id: &ToolId,
334 options: &BTreeMap<String, String>,
335 ) -> Result<CanonicalOptions> {
336 for name in options.keys() {
337 let definition = self.definition(name).ok_or_else(|| {
338 Error::config(format!(
339 "unsupported option `{name}` for dynamic backend `{id}`"
340 ))
341 })?;
342 if name != definition.canonical_name || !definition.identity {
343 return Err(Error::config(
344 "dynamic tool inventory contains non-canonical identity options",
345 ));
346 }
347 }
348 let canonical = self.identity_options(id, options)?;
349 if canonical.as_map() != options {
350 return Err(Error::config(
351 "dynamic tool inventory contains non-canonical identity options",
352 ));
353 }
354 Ok(canonical)
355 }
356
357 pub fn identity_options(
359 &self,
360 id: &ToolId,
361 options: &BTreeMap<String, String>,
362 ) -> Result<CanonicalOptions> {
363 let canonical = self.canonicalize(id, options)?;
364 let identity = canonical
365 .0
366 .into_iter()
367 .filter(|(name, _)| {
368 self.definitions
369 .iter()
370 .find(|definition| definition.canonical_name == name)
371 .is_some_and(|definition| definition.identity)
372 })
373 .collect();
374 Ok(CanonicalOptions(identity))
375 }
376}
377
378#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
380pub struct CanonicalOptions(BTreeMap<String, String>);
381
382impl CanonicalOptions {
383 pub fn new() -> Self {
384 Self::default()
385 }
386
387 pub fn as_map(&self) -> &BTreeMap<String, String> {
388 &self.0
389 }
390
391 pub fn into_map(self) -> BTreeMap<String, String> {
392 self.0
393 }
394
395 pub fn is_empty(&self) -> bool {
396 self.0.is_empty()
397 }
398
399 pub fn get(&self, name: &str) -> Option<&String> {
400 self.0.get(name)
401 }
402
403 pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
404 self.0.iter()
405 }
406}
407
408impl fmt::Display for CanonicalOptions {
409 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410 for (index, (name, value)) in self.0.iter().enumerate() {
411 if index != 0 {
412 formatter.write_str(",")?;
413 }
414 write!(formatter, "{name}=")?;
415 write_option_value(formatter, value)?;
416 }
417 Ok(())
418 }
419}
420
421#[derive(Debug)]
423pub struct NamespaceSchema {
424 pub namespace: &'static str,
425 subject_canonicalizer: fn(&str) -> Result<String>,
426 selector_validator: SelectorValidator,
427 pub options: OptionSchema,
428}
429
430impl NamespaceSchema {
431 pub fn canonicalize_subject(&self, subject: &str) -> Result<String> {
432 (self.subject_canonicalizer)(subject)
433 }
434
435 pub fn validate_selector(&self, id: &ToolId, selector: Option<&str>) -> Result<()> {
436 (self.selector_validator)(id, selector)
437 }
438
439 pub fn canonicalize_options(
440 &self,
441 id: &ToolId,
442 options: &BTreeMap<String, String>,
443 ) -> Result<CanonicalOptions> {
444 self.options.canonicalize(id, options)
445 }
446
447 pub fn identity_options(
448 &self,
449 id: &ToolId,
450 options: &BTreeMap<String, String>,
451 ) -> Result<CanonicalOptions> {
452 self.options.identity_options(id, options)
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct ToolSpecParts {
460 pub id: String,
461 pub options: BTreeMap<String, String>,
462 pub selector: Option<String>,
463}
464
465impl ToolSpecParts {
466 pub fn parse(input: &str) -> Result<Self> {
467 parse_tool_spec_parts(input)
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct ToolSpec {
474 pub id: ToolId,
475 pub options: CanonicalOptions,
476 pub selector: Option<String>,
477}
478
479impl ToolSpec {
480 pub fn parse(input: &str) -> Result<Self> {
481 let parts = ToolSpecParts::parse(input)?;
482 let id = ToolId::parse(&parts.id)?;
483 let options = match id.schema() {
484 Some(schema) => {
485 if let Some(private) = parts
486 .options
487 .keys()
488 .find(|name| name.starts_with("__osdk_"))
489 {
490 return Err(Error::config(format!(
491 "internal option `{private}` cannot be set in a tool request"
492 )));
493 }
494 schema.canonicalize_options(&id, &parts.options)?
495 }
496 None if parts.options.is_empty() => CanonicalOptions::new(),
497 None => {
498 let name = parts.options.keys().next().expect("map is not empty");
499 return Err(Error::config(format!(
500 "unsupported option `{name}` for fixed backend `{id}`"
501 )));
502 }
503 };
504 if let Some(schema) = id.schema() {
505 schema.validate_selector(&id, parts.selector.as_deref())?;
506 }
507 let selector = parts.selector.filter(|selector| !selector.is_empty());
508 Ok(Self {
509 id,
510 options,
511 selector,
512 })
513 }
514
515 pub fn selector(&self) -> Option<&str> {
516 self.selector.as_deref()
517 }
518}
519
520impl fmt::Display for ToolSpec {
521 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
522 write!(formatter, "{}", self.id)?;
523 if !self.options.is_empty() {
524 write!(formatter, "[{}]", self.options)?;
525 }
526 if let Some(selector) = &self.selector {
527 write!(formatter, "@{selector}")?;
528 }
529 Ok(())
530 }
531}
532
533impl std::str::FromStr for ToolSpec {
534 type Err = Error;
535
536 fn from_str(value: &str) -> Result<Self> {
537 Self::parse(value)
538 }
539}
540
541pub fn namespace_schema(namespace: &str) -> Option<&'static NamespaceSchema> {
544 match namespace {
545 "npm" => Some(&NPM_SCHEMA),
546 "github" => Some(&GITHUB_SCHEMA),
547 "http" => Some(&HTTP_SCHEMA),
548 "cargo" => Some(&CARGO_SCHEMA),
549 "go" => Some(&GO_SCHEMA),
550 _ => None,
551 }
552}
553
554pub fn canonical_dynamic_id(value: &str) -> Result<String> {
556 let id = ToolId::parse(value)?;
557 if !id.is_dynamic() {
558 return Err(Error::config(format!(
559 "dynamic tool id must be namespaced: `{value}`"
560 )));
561 }
562 Ok(id.to_string())
563}
564
565pub fn canonicalize_dynamic_options(
567 id: &ToolId,
568 options: &BTreeMap<String, String>,
569) -> Result<CanonicalOptions> {
570 id.schema()
571 .ok_or_else(|| Error::config(format!("dynamic tool id must be namespaced: `{id}`")))?
572 .canonicalize_options(id, options)
573}
574
575pub fn dynamic_identity_options(
577 id: &ToolId,
578 options: &BTreeMap<String, String>,
579) -> Result<CanonicalOptions> {
580 id.schema()
581 .ok_or_else(|| Error::config(format!("dynamic tool id must be namespaced: `{id}`")))?
582 .identity_options(id, options)
583}
584
585pub fn validate_canonical_identity_options(
587 id: &ToolId,
588 options: &BTreeMap<String, String>,
589) -> Result<CanonicalOptions> {
590 id.schema()
591 .ok_or_else(|| Error::config(format!("dynamic tool id must be namespaced: `{id}`")))?
592 .options
593 .validate_canonical_identity(id, options)
594}
595
596pub fn validate_dynamic_selector(id: &ToolId, selector: Option<&str>) -> Result<()> {
600 id.schema()
601 .ok_or_else(|| Error::config(format!("dynamic tool id must be namespaced: `{id}`")))?
602 .validate_selector(id, selector)
603}
604
605const NPM_OPTIONS: &[OptionDefinition] = &[
606 OptionDefinition {
607 name: "allow_builds",
608 canonical_name: "allow_builds",
609 effect: OptionEffect::Artifact,
610 identity: true,
611 canonicalizer: canonical_npm_allow_builds,
612 },
613 OptionDefinition {
614 name: "installer",
615 canonical_name: "installer",
616 effect: OptionEffect::Artifact,
617 identity: true,
618 canonicalizer: canonical_npm_installer,
619 },
620];
621
622const GITHUB_OPTIONS: &[OptionDefinition] = &[
623 option(
624 "arch",
625 "arch",
626 OptionEffect::Resolution,
627 true,
628 canonical_arch,
629 ),
630 option(
631 "asset-regex",
632 "asset-regex",
633 OptionEffect::Resolution,
634 true,
635 canonical_regex,
636 ),
637 option(
638 "asset-template",
639 "asset-template",
640 OptionEffect::Resolution,
641 true,
642 canonical_exact,
643 ),
644 option("bin", "bins", OptionEffect::Layout, true, canonical_bins),
645 option("bins", "bins", OptionEffect::Layout, true, canonical_bins),
646 option(
647 "catalog-sha256",
648 "catalog-sha256",
649 OptionEffect::Artifact,
650 true,
651 canonical_sha256,
652 ),
653 option(
654 "catalog-subdir",
655 "catalog-subdir",
656 OptionEffect::Layout,
657 true,
658 canonical_exact,
659 ),
660 option(
663 "catalog-url",
664 "catalog-url",
665 OptionEffect::Resolution,
666 false,
667 canonical_catalog_url,
668 ),
669 option(
670 "libc",
671 "libc",
672 OptionEffect::Resolution,
673 true,
674 canonical_libc,
675 ),
676 option("os", "os", OptionEffect::Resolution, true, canonical_os),
677 option(
678 "rename",
679 "rename",
680 OptionEffect::Layout,
681 true,
682 canonical_exact,
683 ),
684 option(
685 "strip-components",
686 "strip-components",
687 OptionEffect::Layout,
688 true,
689 canonical_usize,
690 ),
691];
692
693const HTTP_OPTIONS: &[OptionDefinition] = &[
694 option(
695 "sha256",
696 "sha256",
697 OptionEffect::Artifact,
698 true,
699 canonical_http_sha256,
700 ),
701 option(
702 "kind",
703 "kind",
704 OptionEffect::Artifact,
705 true,
706 canonical_http_kind,
707 ),
708 option(
709 "bin",
710 "bins",
711 OptionEffect::Layout,
712 true,
713 canonical_http_bins,
714 ),
715 option(
716 "bins",
717 "bins",
718 OptionEffect::Layout,
719 true,
720 canonical_http_bins,
721 ),
722 option(
723 "subdir",
724 "subdir",
725 OptionEffect::Layout,
726 true,
727 canonical_http_relative_path,
728 ),
729 option(
730 "rename",
731 "rename",
732 OptionEffect::Layout,
733 true,
734 canonical_http_basename,
735 ),
736 option(
737 "strip-components",
738 "strip-components",
739 OptionEffect::Layout,
740 true,
741 canonical_http_strip_components,
742 ),
743];
744
745const CARGO_OPTIONS: &[OptionDefinition] = &[
746 option(
747 "bin",
748 "bin",
749 OptionEffect::Layout,
750 true,
751 canonical_cargo_bin,
752 ),
753 option(
754 "crate",
755 "crate",
756 OptionEffect::Artifact,
757 true,
758 canonical_cargo_crate,
759 ),
760 option(
761 "default-features",
762 "default-features",
763 OptionEffect::Artifact,
764 true,
765 canonical_cargo_default_features,
766 ),
767 option(
768 "features",
769 "features",
770 OptionEffect::Artifact,
771 true,
772 canonical_cargo_features,
773 ),
774 option(
775 "locked",
776 "locked",
777 OptionEffect::Artifact,
778 true,
779 canonical_cargo_locked,
780 ),
781];
782
783const GO_OPTIONS: &[OptionDefinition] = &[
784 option(
785 "tags",
786 "tags",
787 OptionEffect::Artifact,
788 true,
789 canonical_go_tags,
790 ),
791 option(
792 "env",
793 "env",
794 OptionEffect::Artifact,
795 true,
796 canonical_go_install_env,
797 ),
798];
799
800const fn option(
801 name: &'static str,
802 canonical_name: &'static str,
803 effect: OptionEffect,
804 identity: bool,
805 canonicalizer: fn(&str) -> Result<Option<String>>,
806) -> OptionDefinition {
807 OptionDefinition {
808 name,
809 canonical_name,
810 effect,
811 identity,
812 canonicalizer,
813 }
814}
815
816static NPM_SCHEMA: NamespaceSchema = NamespaceSchema {
817 namespace: "npm",
818 subject_canonicalizer: canonical_npm_subject,
819 selector_validator: validate_any_selector,
820 options: OptionSchema {
821 definitions: NPM_OPTIONS,
822 validator: validate_npm_options,
823 },
824};
825
826static GITHUB_SCHEMA: NamespaceSchema = NamespaceSchema {
827 namespace: "github",
828 subject_canonicalizer: canonical_github_subject,
829 selector_validator: validate_any_selector,
830 options: OptionSchema {
831 definitions: GITHUB_OPTIONS,
832 validator: validate_github_options,
833 },
834};
835
836static HTTP_SCHEMA: NamespaceSchema = NamespaceSchema {
837 namespace: "http",
838 subject_canonicalizer: canonical_http_subject,
839 selector_validator: validate_http_selector,
840 options: OptionSchema {
841 definitions: HTTP_OPTIONS,
842 validator: validate_http_options,
843 },
844};
845
846static CARGO_SCHEMA: NamespaceSchema = NamespaceSchema {
847 namespace: "cargo",
848 subject_canonicalizer: canonical_cargo_subject,
849 selector_validator: validate_cargo_selector,
850 options: OptionSchema {
851 definitions: CARGO_OPTIONS,
852 validator: validate_cargo_options,
853 },
854};
855
856static GO_SCHEMA: NamespaceSchema = NamespaceSchema {
857 namespace: "go",
858 subject_canonicalizer: canonical_go_subject,
859 selector_validator: validate_go_selector,
860 options: OptionSchema {
861 definitions: GO_OPTIONS,
862 validator: validate_go_options,
863 },
864};
865
866fn canonical_fixed_name(value: &str) -> Result<String> {
867 let value = value.trim();
868 if value.is_empty()
869 || value.chars().any(char::is_whitespace)
870 || value
871 .chars()
872 .any(|character| matches!(character, ':' | '@' | '[' | ']' | '/' | '\\'))
873 {
874 return Err(Error::other(format!("invalid tool request `{value}`")));
875 }
876 Ok(value.to_string())
877}
878
879fn canonical_namespace(value: &str) -> Result<String> {
880 let value = value.trim();
881 if value.is_empty()
882 || !value.chars().all(|character| {
883 character.is_ascii_lowercase()
884 || character.is_ascii_digit()
885 || matches!(character, '-' | '_')
886 })
887 {
888 return Err(Error::config(format!(
889 "invalid dynamic backend namespace `{value}`"
890 )));
891 }
892 Ok(value.to_string())
893}
894
895fn canonical_npm_subject(value: &str) -> Result<String> {
896 let normalized = value.trim().to_ascii_lowercase();
897 if let Some(rest) = normalized.strip_prefix('@') {
898 let (scope, name) = rest
899 .split_once('/')
900 .ok_or_else(|| Error::config(format!("invalid npm package id `{normalized}`")))?;
901 if !valid_npm_segment(scope) || !valid_npm_segment(name) || name.contains('/') {
902 return Err(Error::config(format!(
903 "invalid npm package id `{normalized}`"
904 )));
905 }
906 return Ok(format!("@{scope}/{name}"));
907 }
908 if !valid_npm_segment(&normalized) {
909 return Err(Error::config(format!(
910 "invalid npm package id `{normalized}`"
911 )));
912 }
913 Ok(normalized)
914}
915
916fn valid_npm_segment(value: &str) -> bool {
917 !value.is_empty()
918 && value.len() <= 214
919 && value != "."
920 && value != ".."
921 && !is_windows_reserved_component(value)
922 && value.chars().all(|character| {
923 character.is_ascii_lowercase()
924 || character.is_ascii_uppercase()
925 || character.is_ascii_digit()
926 || matches!(character, '-' | '_' | '.')
927 })
928}
929
930fn is_windows_reserved_component(value: &str) -> bool {
931 let trimmed = value.trim_end_matches([' ', '.']);
932 if trimmed.is_empty() {
933 return true;
934 }
935 let device = trimmed.split('.').next().unwrap_or(trimmed);
936 matches!(
937 device.to_ascii_uppercase().as_str(),
938 "CON"
939 | "PRN"
940 | "AUX"
941 | "NUL"
942 | "COM1"
943 | "COM2"
944 | "COM3"
945 | "COM4"
946 | "COM5"
947 | "COM6"
948 | "COM7"
949 | "COM8"
950 | "COM9"
951 | "LPT1"
952 | "LPT2"
953 | "LPT3"
954 | "LPT4"
955 | "LPT5"
956 | "LPT6"
957 | "LPT7"
958 | "LPT8"
959 | "LPT9"
960 )
961}
962
963fn canonical_github_subject(value: &str) -> Result<String> {
964 let value = value.trim().trim_end_matches(".git").to_ascii_lowercase();
965 let (owner, repository) = value
966 .split_once('/')
967 .ok_or_else(|| Error::config(format!("invalid GitHub repository id `{value}`")))?;
968 if !valid_github_component(owner)
969 || !valid_github_component(repository)
970 || repository.contains('/')
971 {
972 return Err(Error::config(format!(
973 "invalid GitHub repository id `{value}`"
974 )));
975 }
976 Ok(format!("{owner}/{repository}"))
977}
978
979fn canonical_cargo_subject(value: &str) -> Result<String> {
980 let value = value.trim();
981 if value.contains("://") || value.starts_with("https:") {
982 canonical_cargo_git_subject(value)
983 } else {
984 canonical_cargo_crate_name(value)
985 }
986}
987
988fn canonical_go_subject(value: &str) -> Result<String> {
989 let value = value.trim();
990 if value.is_empty()
991 || value.len() > 4096
992 || value != value.trim()
993 || value.contains(['\\', '@', '?', '#', '%', ':', '!'])
994 || value.chars().any(char::is_control)
995 || value.chars().any(char::is_whitespace)
996 {
997 return Err(Error::config(
998 "Go tool path must be canonical module or command-path text",
999 ));
1000 }
1001 let components = value.split('/').collect::<Vec<_>>();
1002 if components.len() < 2 || !valid_go_host(components[0]) {
1003 return Err(Error::config(
1004 "Go tool path must start with a lowercase DNS module host and contain a path",
1005 ));
1006 }
1007 if components[1..].iter().any(|component| {
1008 component.is_empty()
1009 || *component == "."
1010 || *component == ".."
1011 || component.starts_with('.')
1012 || component.ends_with('.')
1013 || component.len() > 255
1014 || is_windows_reserved_component(component)
1015 || !component.bytes().all(|byte| {
1016 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
1017 })
1018 }) {
1019 return Err(Error::config(
1020 "Go tool path contains an unsafe or non-canonical component",
1021 ));
1022 }
1023 Ok(value.to_string())
1024}
1025
1026fn valid_go_host(host: &str) -> bool {
1027 host.contains('.')
1028 && host.len() <= 253
1029 && host.split('.').all(|label| {
1030 !label.is_empty()
1031 && label.len() <= 63
1032 && label
1033 .bytes()
1034 .next()
1035 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
1036 && label
1037 .bytes()
1038 .last()
1039 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
1040 && label
1041 .bytes()
1042 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1043 })
1044}
1045
1046fn canonical_cargo_crate_name(value: &str) -> Result<String> {
1047 let value = value.trim().to_ascii_lowercase();
1048 let bytes = value.as_bytes();
1049 if bytes.is_empty()
1050 || bytes.len() > 64
1051 || !bytes[0].is_ascii_alphabetic()
1052 || !bytes[bytes.len() - 1].is_ascii_alphanumeric()
1053 || !bytes.iter().all(|byte| {
1054 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
1055 })
1056 || is_windows_reserved_component(&value)
1057 {
1058 return Err(Error::config(format!(
1059 "invalid Cargo registry crate name `{value}`"
1060 )));
1061 }
1062 Ok(value)
1063}
1064
1065fn canonical_cargo_git_subject(value: &str) -> Result<String> {
1066 if value.is_empty()
1067 || value.len() > 4096
1068 || value.trim() != value
1069 || value.chars().any(char::is_whitespace)
1070 || value.chars().any(char::is_control)
1071 || value.contains(['@', '\\'])
1072 {
1073 return Err(Error::config(
1074 "Cargo Git repository must be canonical HTTPS text without whitespace, credentials, or backslashes",
1075 ));
1076 }
1077
1078 let parsed = reqwest::Url::parse(value)
1079 .map_err(|error| Error::config(format!("invalid Cargo Git repository URL: {error}")))?;
1080 if parsed.scheme() != "https" || parsed.host_str().is_none() {
1081 return Err(Error::config(
1082 "Cargo Git repository must be an absolute HTTPS URL",
1083 ));
1084 }
1085 if !parsed.username().is_empty() || parsed.password().is_some() {
1086 return Err(Error::config(
1087 "Cargo Git repository must not contain credentials",
1088 ));
1089 }
1090 if parsed.query().is_some() || parsed.fragment().is_some() {
1091 return Err(Error::config(
1092 "Cargo Git repository must not contain a query or fragment",
1093 ));
1094 }
1095 if parsed.as_str() != value {
1096 return Err(Error::config(
1097 "Cargo Git repository must use its canonical URL spelling",
1098 ));
1099 }
1100 if parsed.path() == "/" || parsed.path().ends_with('/') {
1101 return Err(Error::config(
1102 "Cargo Git repository URL must identify a repository path",
1103 ));
1104 }
1105 if url_path_has_unsafe_component(value, parsed.path()) {
1106 return Err(Error::config(
1107 "Cargo Git repository URL must not contain path traversal",
1108 ));
1109 }
1110 Ok(value.to_string())
1111}
1112
1113fn url_path_has_unsafe_component(raw_url: &str, parsed_path: &str) -> bool {
1114 let raw_path = raw_url
1115 .strip_prefix("https://")
1116 .and_then(|rest| rest.find('/').map(|offset| &rest[offset..]))
1117 .unwrap_or_default();
1118 [raw_path, parsed_path].into_iter().any(|path| {
1119 let mut decoded = path.as_bytes().to_vec();
1120 loop {
1121 if decoded.iter().any(|byte| byte.is_ascii_control())
1122 || decoded.contains(&b'\\')
1123 || decoded.windows(2).any(|pair| pair == b"//")
1124 || decoded
1125 .split(|byte| *byte == b'/')
1126 .any(|component| component == b"." || component == b"..")
1127 {
1128 return true;
1129 }
1130 if !decoded.contains(&b'%') {
1131 return false;
1132 }
1133 let (next, changed) = percent_decode_url_path(&decoded);
1134 if !changed {
1135 return false;
1136 }
1137 decoded = next;
1138 }
1139 })
1140}
1141
1142fn percent_decode_url_path(path: &[u8]) -> (Vec<u8>, bool) {
1143 let mut decoded = Vec::with_capacity(path.len());
1144 let mut offset = 0;
1145 let mut changed = false;
1146 while offset < path.len() {
1147 if path[offset] == b'%' {
1148 if let (Some(high), Some(low)) = (
1149 path.get(offset + 1)
1150 .and_then(|byte| hexadecimal_nibble(*byte)),
1151 path.get(offset + 2)
1152 .and_then(|byte| hexadecimal_nibble(*byte)),
1153 ) {
1154 decoded.push(high << 4 | low);
1155 offset += 3;
1156 changed = true;
1157 continue;
1158 }
1159 }
1160 decoded.push(path[offset]);
1161 offset += 1;
1162 }
1163 (decoded, changed)
1164}
1165
1166fn hexadecimal_nibble(byte: u8) -> Option<u8> {
1167 match byte {
1168 b'0'..=b'9' => Some(byte - b'0'),
1169 b'a'..=b'f' => Some(byte - b'a' + 10),
1170 b'A'..=b'F' => Some(byte - b'A' + 10),
1171 _ => None,
1172 }
1173}
1174
1175fn canonical_http_subject(value: &str) -> Result<String> {
1176 if value.is_empty()
1177 || value.len() > 4096
1178 || value.trim() != value
1179 || value.chars().any(char::is_whitespace)
1180 || value.chars().any(char::is_control)
1181 || value.contains(['@', '\\'])
1182 {
1183 return Err(Error::config(
1184 "HTTP artifact URL template must be canonical HTTPS text without whitespace, credentials, or backslashes",
1185 ));
1186 }
1187
1188 let mut rendered = String::with_capacity(value.len());
1189 let mut rest = value;
1190 let mut version_placeholders = 0usize;
1191 while let Some(open) = rest.find('{') {
1192 rendered.push_str(&rest[..open]);
1193 let after_open = &rest[open + 1..];
1194 let close = after_open
1195 .find('}')
1196 .ok_or_else(|| Error::config("unterminated HTTP URL template placeholder"))?;
1197 let placeholder = &after_open[..close];
1198 if placeholder != "version" {
1199 return Err(Error::config(format!(
1200 "unsupported HTTP URL template placeholder `{{{placeholder}}}`"
1201 )));
1202 }
1203 version_placeholders += 1;
1204 if version_placeholders > 8 {
1205 return Err(Error::config(
1206 "HTTP URL template may contain at most 8 version placeholders",
1207 ));
1208 }
1209 rendered.push_str("1.2.3");
1210 rest = &after_open[close + 1..];
1211 }
1212 if rest.contains('}') {
1213 return Err(Error::config("unmatched HTTP URL template brace"));
1214 }
1215 rendered.push_str(rest);
1216 if version_placeholders == 0 {
1217 return Err(Error::config(
1218 "HTTP artifact URL template must contain `{version}`",
1219 ));
1220 }
1221
1222 let parsed = reqwest::Url::parse(&rendered)
1223 .map_err(|error| Error::config(format!("invalid HTTP artifact URL template: {error}")))?;
1224 if parsed.scheme() != "https" || parsed.host_str().is_none() {
1225 return Err(Error::config(
1226 "HTTP artifact URL template must be an absolute HTTPS URL",
1227 ));
1228 }
1229 if !parsed.username().is_empty() || parsed.password().is_some() {
1230 return Err(Error::config(
1231 "HTTP artifact URL template must not contain credentials",
1232 ));
1233 }
1234 if parsed.query().is_some() || parsed.fragment().is_some() {
1235 return Err(Error::config(
1236 "HTTP artifact URL template must not contain a query or fragment",
1237 ));
1238 }
1239 if parsed.as_str() != rendered {
1240 return Err(Error::config(
1241 "HTTP artifact URL template must use its canonical URL spelling",
1242 ));
1243 }
1244 if parsed
1245 .host_str()
1246 .and_then(|host| {
1247 host.strip_prefix('[')
1248 .and_then(|host| host.strip_suffix(']'))
1249 .unwrap_or(host)
1250 .parse::<std::net::IpAddr>()
1251 .ok()
1252 })
1253 .is_some_and(|address| !is_public_ip(address))
1254 {
1255 return Err(Error::config(
1256 "HTTP artifact URL template must not target a non-public IP address",
1257 ));
1258 }
1259 let authority_end = value
1260 .strip_prefix("https://")
1261 .and_then(|rest| rest.find('/').map(|offset| "https://".len() + offset))
1262 .ok_or_else(|| Error::config("HTTP artifact URL template requires a path"))?;
1263 if value[..authority_end].contains('{') {
1264 return Err(Error::config(
1265 "`{version}` is allowed only in the HTTP URL path",
1266 ));
1267 }
1268 let path = parsed.path();
1269 if !path.contains("1.2.3") {
1270 return Err(Error::config(
1271 "`{version}` is allowed only in the HTTP URL path",
1272 ));
1273 }
1274 Ok(value.to_string())
1275}
1276
1277pub(crate) fn is_public_ip(address: std::net::IpAddr) -> bool {
1282 match address {
1283 std::net::IpAddr::V4(address) => {
1284 let [a, b, c, _] = address.octets();
1285 !(a == 0
1286 || a == 10
1287 || a == 127
1288 || a >= 224
1289 || (a == 100 && (64..=127).contains(&b))
1290 || (a == 169 && b == 254)
1291 || (a == 172 && (16..=31).contains(&b))
1292 || (a == 192 && b == 0 && c == 0)
1293 || (a == 192 && b == 0 && c == 2)
1294 || (a == 192 && b == 88 && c == 99)
1295 || (a == 192 && b == 168)
1296 || (a == 198 && (b == 18 || b == 19))
1297 || (a == 198 && b == 51 && c == 100)
1298 || (a == 203 && b == 0 && c == 113))
1299 }
1300 std::net::IpAddr::V6(address) => {
1301 if let Some(mapped) = address.to_ipv4_mapped() {
1302 return is_public_ip(std::net::IpAddr::V4(mapped));
1303 }
1304 let segments = address.segments();
1305 !(address.is_unspecified()
1306 || address.is_loopback()
1307 || address.is_multicast()
1308 || segments[0] & 0xfe00 == 0xfc00
1309 || segments[0] & 0xffc0 == 0xfe80
1310 || segments[0] & 0xffc0 == 0xfec0
1311 || (segments[0] == 0 && segments[1..5] == [0, 0, 0, 0])
1312 || (segments[0] == 0x0064 && segments[1] == 0xff9b)
1313 || (segments[0] == 0x2001 && segments[1] <= 0x01ff)
1314 || segments[0] == 0x2002
1315 || (segments[0] & 0xfff0 == 0x3ff0)
1316 || segments[0] == 0x5f00)
1317 }
1318 }
1319}
1320
1321fn valid_github_component(value: &str) -> bool {
1322 !value.is_empty()
1323 && value != "."
1324 && value != ".."
1325 && value.chars().all(|character| {
1326 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1327 })
1328}
1329
1330fn canonical_npm_allow_builds(value: &str) -> Result<Option<String>> {
1331 let value = value.trim();
1332 if value.is_empty()
1333 || matches!(
1334 value.to_ascii_lowercase().as_str(),
1335 "false" | "0" | "no" | "off"
1336 )
1337 {
1338 return Ok(None);
1339 }
1340 if matches!(
1341 value.to_ascii_lowercase().as_str(),
1342 "true" | "1" | "yes" | "on"
1343 ) {
1344 return Ok(Some("true".into()));
1345 }
1346 let mut packages = value
1347 .split(',')
1348 .map(str::trim)
1349 .filter(|package| !package.is_empty())
1350 .map(str::to_ascii_lowercase)
1351 .collect::<Vec<_>>();
1352 if packages
1353 .iter()
1354 .any(|package| canonical_npm_subject(package).is_err())
1355 {
1356 return Err(Error::config(
1357 "allow_builds contains an invalid npm package name",
1358 ));
1359 }
1360 packages.sort();
1361 packages.dedup();
1362 if packages.is_empty() {
1363 return Err(Error::config("allow_builds must not be empty"));
1364 }
1365 Ok(Some(packages.join(",")))
1366}
1367
1368fn canonical_npm_installer(value: &str) -> Result<Option<String>> {
1369 let installer = crate::npm_tools::installer_from_request_options(&BTreeMap::from([(
1370 "installer".to_string(),
1371 value.to_string(),
1372 )]))?;
1373 Ok((installer != crate::npm_tools::NpmInstaller::Auto).then(|| installer.as_str().to_string()))
1374}
1375
1376fn canonical_cargo_features(value: &str) -> Result<Option<String>> {
1377 let mut features = Vec::new();
1378 for raw in value.split(',') {
1379 let feature = raw.trim();
1380 if !valid_cargo_feature(feature) {
1381 return Err(Error::config(format!(
1382 "invalid Cargo feature name `{feature}`"
1383 )));
1384 }
1385 features.push(feature.to_string());
1386 }
1387 features.sort();
1388 features.dedup();
1389 Ok(Some(features.join(",")))
1390}
1391
1392fn valid_cargo_feature(value: &str) -> bool {
1393 if value.is_empty()
1394 || value.len() > 256
1395 || value.chars().any(char::is_whitespace)
1396 || value.chars().any(char::is_control)
1397 || value.contains('\\')
1398 {
1399 return false;
1400 }
1401 if let Some(dependency) = value.strip_prefix("dep:") {
1402 return valid_cargo_feature_atom(dependency);
1403 }
1404 if value.contains(':') {
1405 return false;
1406 }
1407 if let Some((dependency, feature)) = value.split_once('/') {
1408 let dependency = dependency.strip_suffix('?').unwrap_or(dependency);
1409 return !feature.contains('/')
1410 && valid_cargo_feature_atom(dependency)
1411 && valid_cargo_feature_atom(feature);
1412 }
1413 valid_cargo_feature_atom(value)
1414}
1415
1416fn valid_cargo_feature_atom(value: &str) -> bool {
1417 let mut characters = value.chars();
1418 characters
1419 .next()
1420 .is_some_and(|character| character == '_' || character.is_ascii_alphanumeric())
1421 && characters.all(|character| {
1422 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '+' | '.')
1423 })
1424 && value != "."
1425 && value != ".."
1426}
1427
1428fn canonical_cargo_default_features(value: &str) -> Result<Option<String>> {
1429 canonical_cargo_boolean(value, "default-features", true)
1430}
1431
1432fn canonical_cargo_locked(value: &str) -> Result<Option<String>> {
1433 canonical_cargo_boolean(value, "locked", false)
1434}
1435
1436fn canonical_cargo_boolean(value: &str, name: &str, default: bool) -> Result<Option<String>> {
1437 let value = value.trim().to_ascii_lowercase();
1438 let parsed = match value.as_str() {
1439 "true" => true,
1440 "false" => false,
1441 _ => {
1442 return Err(Error::config(format!(
1443 "Cargo option `{name}` must be `true` or `false`"
1444 )));
1445 }
1446 };
1447 Ok((parsed != default).then(|| parsed.to_string()))
1448}
1449
1450fn canonical_cargo_bin(value: &str) -> Result<Option<String>> {
1451 let value = value.trim();
1452 crate::pipeline::validate_safe_filename("Cargo binary name", value)?;
1453 if value.len() > 255
1454 || value.ends_with([' ', '.'])
1455 || is_windows_reserved_component(value)
1456 || value.chars().any(|character| {
1457 character.is_control()
1458 || matches!(
1459 character,
1460 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
1461 )
1462 })
1463 {
1464 return Err(Error::config(format!(
1465 "Cargo binary name is not portable: `{value}`"
1466 )));
1467 }
1468 Ok(Some(value.to_string()))
1469}
1470
1471fn canonical_cargo_crate(value: &str) -> Result<Option<String>> {
1472 canonical_cargo_crate_name(value).map(Some)
1473}
1474
1475fn canonical_go_tags(value: &str) -> Result<Option<String>> {
1476 let mut tags = Vec::new();
1477 for raw in value.split(',') {
1478 let tag = raw.trim();
1479 if tag.is_empty()
1480 || tag.len() > 128
1481 || !tag
1482 .bytes()
1483 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.'))
1484 {
1485 return Err(Error::config(format!("invalid Go build tag `{tag}`")));
1486 }
1487 tags.push(tag.to_string());
1488 }
1489 tags.sort();
1490 tags.dedup();
1491 Ok(Some(tags.join(",")))
1492}
1493
1494fn canonical_go_install_env(value: &str) -> Result<Option<String>> {
1495 let mut values = BTreeMap::new();
1496 for assignment in value.split(';') {
1497 let (name, value) = assignment.split_once('=').ok_or_else(|| {
1498 Error::config("Go install env must use KEY=value assignments separated by `;`")
1499 })?;
1500 if name.trim() != name || value.trim() != value || value.is_empty() {
1501 return Err(Error::config(
1502 "Go install env contains a non-canonical assignment",
1503 ));
1504 }
1505 validate_go_install_env_value(name, value)?;
1506 if values.insert(name, value).is_some() {
1507 return Err(Error::config(format!(
1508 "duplicate Go install env key `{name}`"
1509 )));
1510 }
1511 }
1512 if values.is_empty() {
1513 return Err(Error::config("Go install env must not be empty"));
1514 }
1515 Ok(Some(
1516 values
1517 .into_iter()
1518 .map(|(name, value)| format!("{name}={value}"))
1519 .collect::<Vec<_>>()
1520 .join(";"),
1521 ))
1522}
1523
1524fn validate_go_install_env_value(name: &str, value: &str) -> Result<()> {
1525 let valid = match name {
1526 "CGO_ENABLED" => value == "0",
1530 "GOAMD64" => matches!(value, "v1" | "v2" | "v3" | "v4"),
1531 "GO386" => matches!(value, "softfloat" | "sse2"),
1532 "GOARM" => matches!(value, "5" | "6" | "7"),
1533 "GOMIPS" | "GOMIPS64" => matches!(value, "hardfloat" | "softfloat"),
1534 "GOEXPERIMENT" => {
1535 value.len() <= 512
1536 && value.split(',').all(|experiment| {
1537 !experiment.is_empty()
1538 && experiment
1539 .bytes()
1540 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
1541 })
1542 }
1543 _ => false,
1544 };
1545 if !valid {
1546 return Err(Error::config(format!(
1547 "unsupported or invalid Go install env `{name}`; credentials and network/cache overrides are not accepted"
1548 )));
1549 }
1550 Ok(())
1551}
1552
1553fn canonical_exact(value: &str) -> Result<Option<String>> {
1554 reject_control_characters(value)?;
1555 Ok(Some(value.to_string()))
1556}
1557
1558fn canonical_regex(value: &str) -> Result<Option<String>> {
1559 reject_control_characters(value)?;
1560 Ok(Some(value.to_string()))
1561}
1562
1563fn canonical_os(value: &str) -> Result<Option<String>> {
1564 let value = value.trim().to_ascii_lowercase();
1565 let value = match value.as_str() {
1566 "darwin" => "macos",
1567 "linux" | "macos" | "windows" => value.as_str(),
1568 _ => return Err(Error::config("invalid GitHub target os")),
1569 };
1570 Ok(Some(value.to_string()))
1571}
1572
1573fn canonical_arch(value: &str) -> Result<Option<String>> {
1574 let value = value.trim().to_ascii_lowercase();
1575 let value = match value.as_str() {
1576 "x86_64" | "amd64" => "x64",
1577 "aarch64" => "arm64",
1578 "i686" => "x86",
1579 "armv7" => "arm",
1580 "x64" | "arm64" | "x86" | "arm" => value.as_str(),
1581 _ => return Err(Error::config("invalid GitHub target arch")),
1582 };
1583 Ok(Some(value.to_string()))
1584}
1585
1586fn canonical_libc(value: &str) -> Result<Option<String>> {
1587 let value = value.trim().to_ascii_lowercase();
1588 if !matches!(value.as_str(), "gnu" | "musl" | "none") {
1589 return Err(Error::config("invalid GitHub target libc"));
1590 }
1591 Ok(Some(value))
1592}
1593
1594fn canonical_bins(value: &str) -> Result<Option<String>> {
1595 let bins = value
1596 .split(',')
1597 .map(str::trim)
1598 .filter(|bin| !bin.is_empty())
1599 .map(str::to_string)
1600 .collect::<Vec<_>>();
1601 Ok(Some(bins.join(",")))
1602}
1603
1604fn canonical_usize(value: &str) -> Result<Option<String>> {
1605 let value = value.trim();
1606 Ok(Some(
1607 value
1608 .parse::<usize>()
1609 .map_err(|error| Error::config(format!("invalid strip-components `{value}`: {error}")))?
1610 .to_string(),
1611 ))
1612}
1613
1614fn canonical_sha256(value: &str) -> Result<Option<String>> {
1615 let value = value.trim().to_ascii_lowercase();
1616 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1617 return Err(Error::config(
1618 "catalog-sha256 must be a 64-character hexadecimal SHA-256 digest",
1619 ));
1620 }
1621 Ok(Some(value))
1622}
1623
1624fn canonical_http_sha256(value: &str) -> Result<Option<String>> {
1625 let value = value.trim().to_ascii_lowercase();
1626 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1627 return Err(Error::config(
1628 "sha256 must be a 64-character hexadecimal SHA-256 digest",
1629 ));
1630 }
1631 Ok(Some(value))
1632}
1633
1634fn canonical_http_kind(value: &str) -> Result<Option<String>> {
1635 let value = value.trim().to_ascii_lowercase();
1636 if !matches!(value.as_str(), "tar.gz" | "tar.xz" | "zip" | "file") {
1637 return Err(Error::config(
1638 "invalid HTTP artifact kind (expected tar.gz|tar.xz|zip|file)",
1639 ));
1640 }
1641 Ok(Some(value))
1642}
1643
1644fn canonical_http_bins(value: &str) -> Result<Option<String>> {
1645 let mut bins = Vec::new();
1646 for raw in value.split(',') {
1647 let path = canonical_safe_relative_path("HTTP bin path", raw)?;
1648 bins.push(path);
1649 }
1650 bins.sort();
1651 bins.dedup();
1652 if bins.is_empty() {
1653 return Err(Error::config("HTTP bins must not be empty"));
1654 }
1655 Ok(Some(bins.join(",")))
1656}
1657
1658fn canonical_http_relative_path(value: &str) -> Result<Option<String>> {
1659 canonical_safe_relative_path("HTTP subdir", value).map(Some)
1660}
1661
1662pub(crate) fn canonical_safe_relative_path(label: &str, value: &str) -> Result<String> {
1663 let value = value.trim();
1664 if value.is_empty() || value.contains(['\\', ':']) {
1665 return Err(Error::config(format!("unsafe {label} `{value}`")));
1666 }
1667 let path = std::path::Path::new(value);
1668 if path.is_absolute()
1669 || path
1670 .components()
1671 .any(|component| !matches!(component, std::path::Component::Normal(_)))
1672 || path.components().any(|component| {
1673 let component = component.as_os_str().to_string_lossy();
1674 component.ends_with([' ', '.']) || is_windows_reserved_component(&component)
1675 })
1676 {
1677 return Err(Error::config(format!("unsafe {label} `{value}`")));
1678 }
1679 Ok(path
1680 .components()
1681 .map(|component| component.as_os_str().to_string_lossy())
1682 .collect::<Vec<_>>()
1683 .join("/"))
1684}
1685
1686fn canonical_http_basename(value: &str) -> Result<Option<String>> {
1687 let value = value.trim();
1688 crate::pipeline::validate_safe_filename("HTTP executable rename", value)?;
1689 if value.ends_with([' ', '.']) || is_windows_reserved_component(value) {
1690 return Err(Error::config(format!(
1691 "unsafe HTTP executable rename `{value}`"
1692 )));
1693 }
1694 Ok(Some(value.to_string()))
1695}
1696
1697fn canonical_http_strip_components(value: &str) -> Result<Option<String>> {
1698 let value = value.trim();
1699 Ok(Some(
1700 value
1701 .parse::<u32>()
1702 .map_err(|error| Error::config(format!("invalid strip-components `{value}`: {error}")))?
1703 .to_string(),
1704 ))
1705}
1706
1707fn canonical_catalog_url(value: &str) -> Result<Option<String>> {
1708 reject_control_characters(value)?;
1709 let lower = value.to_ascii_lowercase();
1710 if lower.starts_with("http://") || lower.starts_with("https://") {
1711 let parsed = reqwest::Url::parse(value)
1712 .map_err(|error| Error::config(format!("invalid GitHub catalog URL: {error}")))?;
1713 if !parsed.username().is_empty() || parsed.password().is_some() {
1714 return Err(Error::config(
1715 "GitHub catalog URL must not contain credentials",
1716 ));
1717 }
1718 if parsed.query().is_some() || parsed.fragment().is_some() {
1719 return Err(Error::config(
1720 "GitHub catalog URL must not contain a query or fragment",
1721 ));
1722 }
1723 }
1724 Ok(Some(value.to_string()))
1725}
1726
1727fn validate_npm_options(
1728 _id: &ToolId,
1729 _raw: &BTreeMap<String, String>,
1730 _canonical: &CanonicalOptions,
1731) -> Result<()> {
1732 Ok(())
1733}
1734
1735fn validate_any_selector(_id: &ToolId, _selector: Option<&str>) -> Result<()> {
1736 Ok(())
1737}
1738
1739fn validate_http_selector(_id: &ToolId, selector: Option<&str>) -> Result<()> {
1740 let Some(selector) = selector else {
1741 return Err(Error::config(
1742 "HTTP artifacts require an exact semantic version selector",
1743 ));
1744 };
1745 if selector.len() > 128
1746 || !matches!(crate::version::VersionSpec::parse(selector), crate::version::VersionSpec::Exact(version) if version == selector.trim_start_matches('v'))
1747 {
1748 return Err(Error::config(
1749 "HTTP artifacts require an exact semantic version selector",
1750 ));
1751 }
1752 Ok(())
1753}
1754
1755fn validate_cargo_selector(id: &ToolId, selector: Option<&str>) -> Result<()> {
1756 let Some(selector) = selector else {
1757 return Ok(());
1758 };
1759 if selector.is_empty() || selector.trim() != selector || selector.len() > 1024 {
1760 return Err(Error::config("invalid Cargo selector"));
1761 }
1762 if id.subject().starts_with("https://") {
1763 if selector == "latest" {
1764 return Ok(());
1765 }
1766 let valid = selector
1767 .strip_prefix("tag:")
1768 .or_else(|| selector.strip_prefix("branch:"))
1769 .is_some_and(valid_cargo_git_ref)
1770 || selector.strip_prefix("rev:").is_some_and(|revision| {
1771 revision.len() == 40
1772 && revision
1773 .bytes()
1774 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1775 });
1776 if !valid {
1777 return Err(Error::config(
1778 "Cargo Git selectors must be latest, tag:<ref>, branch:<ref>, or rev:<40 lowercase hex>",
1779 ));
1780 }
1781 return Ok(());
1782 }
1783
1784 let valid = selector == "latest"
1785 || matches!(
1786 crate::version::VersionSpec::parse(selector),
1787 crate::version::VersionSpec::Exact(version)
1788 if version == selector
1789 )
1790 || valid_cargo_semver_prefix(selector);
1791 if !valid {
1792 return Err(Error::config(
1793 "Cargo registry selectors must be latest, an exact semantic version, or a numeric semantic-version prefix",
1794 ));
1795 }
1796 Ok(())
1797}
1798
1799fn validate_go_selector(_id: &ToolId, selector: Option<&str>) -> Result<()> {
1800 let Some(selector) = selector else {
1801 return Ok(());
1802 };
1803 if selector == "latest" || valid_cargo_semver_prefix(selector) {
1804 return Ok(());
1805 }
1806 if selector.len() <= 256 && is_canonical_go_module_version(selector) {
1807 return Ok(());
1808 }
1809 Err(Error::config(
1810 "Go tool selectors must be latest, an exact semantic or pseudo-version, or a numeric semantic-version prefix",
1811 ))
1812}
1813
1814pub fn is_canonical_go_module_version(value: &str) -> bool {
1815 if value.starts_with('v') {
1816 return false;
1817 }
1818 let Ok(version) = semver::Version::parse(value) else {
1819 return false;
1820 };
1821 if !version.build.is_empty() && version.build.as_str() != "incompatible" {
1822 return false;
1823 }
1824 !looks_like_go_pseudo_version(&version) || valid_go_pseudo_version_text(value)
1825}
1826
1827fn looks_like_go_pseudo_version(version: &semver::Version) -> bool {
1828 let Some((before_revision, _)) = version.pre.as_str().rsplit_once('-') else {
1829 return false;
1830 };
1831 before_revision
1832 .rsplit_once('.')
1833 .map_or(before_revision, |(_, timestamp)| timestamp)
1834 .bytes()
1835 .all(|byte| byte.is_ascii_digit())
1836}
1837
1838fn valid_go_pseudo_version_text(value: &str) -> bool {
1839 let Ok(version) = semver::Version::parse(value) else {
1840 return false;
1841 };
1842 if !version.build.is_empty() && version.build.as_str() != "incompatible" {
1843 return false;
1844 }
1845 let pre = version.pre.as_str();
1846 let Some((before_hash, revision)) = pre.rsplit_once('-') else {
1847 return false;
1848 };
1849 let (prefix, timestamp) = before_hash
1850 .rsplit_once('.')
1851 .map_or((None, before_hash), |(prefix, timestamp)| {
1852 (Some(prefix), timestamp)
1853 });
1854 let valid_prefix = match prefix {
1855 None => version.minor == 0 && version.patch == 0,
1856 Some("0") => true,
1857 Some(prefix) => prefix.ends_with(".0"),
1858 };
1859 valid_prefix
1860 && timestamp.len() == 14
1861 && timestamp.bytes().all(|byte| byte.is_ascii_digit())
1862 && valid_go_pseudo_timestamp(timestamp)
1863 && revision.len() == 12
1864 && revision
1865 .bytes()
1866 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1867}
1868
1869fn valid_go_pseudo_timestamp(value: &str) -> bool {
1870 if value.len() != 14 || !value.is_ascii() {
1871 return false;
1872 }
1873 let parse = |range: std::ops::Range<usize>| value[range].parse::<u32>().ok();
1874 let Some(year) = parse(0..4) else {
1875 return false;
1876 };
1877 let Some(month) = parse(4..6) else {
1878 return false;
1879 };
1880 let Some(day) = parse(6..8) else {
1881 return false;
1882 };
1883 let Some(hour) = parse(8..10) else {
1884 return false;
1885 };
1886 let Some(minute) = parse(10..12) else {
1887 return false;
1888 };
1889 let Some(second) = parse(12..14) else {
1890 return false;
1891 };
1892 if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
1893 return false;
1894 }
1895 let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
1896 let max_day = match month {
1897 2 if leap => 29,
1898 2 => 28,
1899 4 | 6 | 9 | 11 => 30,
1900 _ => 31,
1901 };
1902 (1..=max_day).contains(&day)
1903}
1904
1905fn valid_cargo_semver_prefix(value: &str) -> bool {
1906 let mut components = value.split('.');
1907 let first = components.next();
1908 let second = components.next();
1909 components.next().is_none()
1910 && first.is_some_and(valid_cargo_version_component)
1911 && second.is_none_or(valid_cargo_version_component)
1912}
1913
1914fn valid_cargo_version_component(value: &str) -> bool {
1915 !value.is_empty()
1916 && value.bytes().all(|byte| byte.is_ascii_digit())
1917 && (value == "0" || !value.starts_with('0'))
1918}
1919
1920fn valid_cargo_git_ref(value: &str) -> bool {
1921 !value.is_empty()
1922 && value != "@"
1923 && !value.starts_with(['/', '.'])
1924 && !value.ends_with(['/', '.'])
1925 && !["..", "@{", "//", "\\"]
1926 .iter()
1927 .any(|needle| value.contains(needle))
1928 && !value.ends_with(".lock")
1929 && !value.chars().any(char::is_whitespace)
1930 && !value.chars().any(char::is_control)
1931 && !value
1932 .chars()
1933 .any(|character| matches!(character, '~' | '^' | ':' | '?' | '*' | '['))
1934 && value.split('/').all(|component| {
1935 !component.is_empty()
1936 && !component.starts_with('.')
1937 && !component.ends_with('.')
1938 && !component.ends_with(".lock")
1939 })
1940}
1941
1942fn validate_cargo_options(
1943 id: &ToolId,
1944 _raw: &BTreeMap<String, String>,
1945 canonical: &CanonicalOptions,
1946) -> Result<()> {
1947 if canonical.get("crate").is_some() && !id.subject().starts_with("https://") {
1948 return Err(Error::config(
1949 "Cargo option `crate` is supported only for Git repositories",
1950 ));
1951 }
1952 Ok(())
1953}
1954
1955fn validate_go_options(
1956 _id: &ToolId,
1957 _raw: &BTreeMap<String, String>,
1958 _canonical: &CanonicalOptions,
1959) -> Result<()> {
1960 Ok(())
1961}
1962
1963fn validate_http_options(
1964 id: &ToolId,
1965 raw: &BTreeMap<String, String>,
1966 canonical: &CanonicalOptions,
1967) -> Result<()> {
1968 if raw.contains_key("bin") && raw.contains_key("bins") {
1969 return Err(Error::config("bin and bins are mutually exclusive"));
1970 }
1971 if canonical.get("sha256").is_none() {
1972 return Err(Error::config("sha256 is required for HTTP artifacts"));
1973 }
1974 let inferred_kind;
1975 let kind = if let Some(kind) = canonical.get("kind") {
1976 kind.as_str()
1977 } else {
1978 inferred_kind = match crate::pipeline::ArchiveKind::from_name(id.subject()) {
1979 Ok(crate::pipeline::ArchiveKind::TarGz) => "tar.gz",
1980 Ok(crate::pipeline::ArchiveKind::TarXz) => "tar.xz",
1981 Ok(crate::pipeline::ArchiveKind::Zip) => "zip",
1982 Ok(crate::pipeline::ArchiveKind::TarZst) => {
1983 return Err(Error::config(
1984 "HTTP artifacts support tar.gz, tar.xz, zip, or file",
1985 ));
1986 }
1987 Err(_) => "file",
1988 };
1989 inferred_kind
1990 };
1991 let bins = canonical
1992 .get("bins")
1993 .map(|value| value.split(',').count())
1994 .unwrap_or(0);
1995 if kind != "file" && bins == 0 {
1996 return Err(Error::config(
1997 "HTTP archives require at least one bin or bins entry",
1998 ));
1999 }
2000 if canonical.get("rename").is_some() && kind != "file" && bins != 1 {
2001 return Err(Error::config(
2002 "rename requires exactly one bin for HTTP archives",
2003 ));
2004 }
2005 if kind == "file"
2006 && (bins != 0
2007 || canonical.get("subdir").is_some()
2008 || canonical.get("strip-components").is_some())
2009 {
2010 return Err(Error::config(
2011 "HTTP file artifacts do not accept bin, bins, subdir, or strip-components",
2012 ));
2013 }
2014 Ok(())
2015}
2016
2017fn validate_github_options(
2018 _id: &ToolId,
2019 raw: &BTreeMap<String, String>,
2020 canonical: &CanonicalOptions,
2021) -> Result<()> {
2022 if raw.contains_key("bin") && raw.contains_key("bins") {
2023 return Err(Error::config("bin and bins are mutually exclusive"));
2024 }
2025 if raw.contains_key("asset-regex") && raw.contains_key("asset-template") {
2026 return Err(Error::config(
2027 "asset-regex and asset-template are mutually exclusive",
2028 ));
2029 }
2030 if canonical.get("catalog-url").is_some() && canonical.get("catalog-sha256").is_none() {
2031 return Err(Error::config("catalog-sha256 is required with catalog-url"));
2032 }
2033 Ok(())
2034}
2035
2036fn reject_control_characters(value: &str) -> Result<()> {
2037 if value.chars().any(char::is_control) {
2038 return Err(Error::config(
2039 "option values must not contain control characters",
2040 ));
2041 }
2042 Ok(())
2043}
2044
2045fn parse_tool_spec_parts(input: &str) -> Result<ToolSpecParts> {
2046 let input = input.trim();
2047 if input.is_empty() {
2048 return Err(invalid_request(input));
2049 }
2050
2051 if let Some((open, close)) = find_option_block(input)? {
2052 let id = input[..open].trim();
2053 if id.is_empty() {
2054 return Err(invalid_request(input));
2055 }
2056 let options = parse_options(&input[open + 1..close])?;
2057 let tail = input[close + 1..].trim();
2058 let selector = if tail.is_empty() {
2059 None
2060 } else if let Some(selector) = tail.strip_prefix('@') {
2061 Some(selector.trim().to_string())
2062 } else {
2063 return Err(invalid_request(input));
2064 };
2065 return Ok(ToolSpecParts {
2066 id: id.to_string(),
2067 options,
2068 selector,
2069 });
2070 }
2071
2072 let (id, selector) = split_selector(input);
2073 if id.trim().is_empty() {
2074 return Err(invalid_request(input));
2075 }
2076 Ok(ToolSpecParts {
2077 id: id.trim().to_string(),
2078 options: BTreeMap::new(),
2079 selector: selector.map(|selector| selector.trim().to_string()),
2080 })
2081}
2082
2083fn find_option_block(input: &str) -> Result<Option<(usize, usize)>> {
2084 for (open, character) in input.char_indices() {
2085 if character != '[' {
2086 continue;
2087 }
2088 let close = find_option_close(input, open)?
2089 .ok_or_else(|| Error::config("unterminated dynamic tool option block"))?;
2090 let body = &input[open + 1..close];
2091 if !body.trim().is_empty() && !contains_unquoted_equals(body)? {
2092 continue;
2093 }
2094 let tail = input[close + 1..].trim();
2095 if tail.is_empty() || tail.starts_with('@') {
2096 return Ok(Some((open, close)));
2097 }
2098 }
2099 if input.contains(']') {
2100 return Err(Error::config("unmatched dynamic tool option bracket"));
2101 }
2102 Ok(None)
2103}
2104
2105fn find_option_close(input: &str, open: usize) -> Result<Option<usize>> {
2106 let mut quote = None;
2107 let mut escaped = false;
2108 for (offset, character) in input[open + 1..].char_indices() {
2109 if escaped {
2110 escaped = false;
2111 continue;
2112 }
2113 if character == '\\' {
2114 escaped = true;
2115 continue;
2116 }
2117 if let Some(active) = quote {
2118 if character == active {
2119 quote = None;
2120 }
2121 continue;
2122 }
2123 if matches!(character, '\'' | '"') {
2124 quote = Some(character);
2125 } else if character == '[' {
2126 return Err(Error::config("nested option brackets are not supported"));
2127 } else if character == ']' {
2128 return Ok(Some(open + 1 + offset));
2129 }
2130 }
2131 Ok(None)
2132}
2133
2134fn contains_unquoted_equals(input: &str) -> Result<bool> {
2135 let mut quote = None;
2136 let mut escaped = false;
2137 for character in input.chars() {
2138 if escaped {
2139 escaped = false;
2140 continue;
2141 }
2142 if character == '\\' {
2143 escaped = true;
2144 continue;
2145 }
2146 if let Some(active) = quote {
2147 if character == active {
2148 quote = None;
2149 }
2150 } else if matches!(character, '\'' | '"') {
2151 quote = Some(character);
2152 } else if character == '=' {
2153 return Ok(true);
2154 }
2155 }
2156 if quote.is_some() {
2157 return Err(Error::config("unterminated quoted option value"));
2158 }
2159 Ok(false)
2160}
2161
2162fn split_selector(input: &str) -> (&str, Option<&str>) {
2163 let Some((namespace, subject)) = input.split_once(':') else {
2164 return input
2165 .split_once('@')
2166 .map_or((input, None), |(id, selector)| (id, Some(selector)));
2167 };
2168
2169 if let Some(schema) = namespace_schema(namespace) {
2170 if schema.canonicalize_subject(subject).is_ok() {
2171 return (input, None);
2172 }
2173 for (offset, character) in subject.char_indices().rev() {
2174 if character != '@' || is_url_authority_at(subject, offset) {
2175 continue;
2176 }
2177 let candidate = &subject[..offset];
2178 let selector = &subject[offset + 1..];
2179 let candidate_id =
2180 schema
2181 .canonicalize_subject(candidate)
2182 .ok()
2183 .map(|subject| ToolId::Dynamic {
2184 namespace: namespace.to_string(),
2185 subject,
2186 });
2187 if candidate_id
2188 .as_ref()
2189 .is_some_and(|id| schema.validate_selector(id, Some(selector)).is_ok())
2190 {
2191 let delimiter = namespace.len() + 1 + offset;
2192 return (&input[..delimiter], Some(&input[delimiter + 1..]));
2193 }
2194 }
2195 }
2196
2197 if subject.contains("://") {
2198 for (offset, character) in subject.char_indices().rev() {
2199 if character != '@' || is_url_authority_at(subject, offset) {
2200 continue;
2201 }
2202 let delimiter = namespace.len() + 1 + offset;
2203 return (&input[..delimiter], Some(&input[delimiter + 1..]));
2204 }
2205 return (input, None);
2206 }
2207
2208 for (offset, character) in subject.char_indices().rev() {
2209 if character != '@' || is_url_authority_at(subject, offset) {
2210 continue;
2211 }
2212 if offset == 0 && subject[1..].contains('/') {
2213 continue;
2214 }
2215 let delimiter = namespace.len() + 1 + offset;
2216 return (&input[..delimiter], Some(&input[delimiter + 1..]));
2217 }
2218 (input, None)
2219}
2220
2221fn is_url_authority_at(subject: &str, at: usize) -> bool {
2222 let Some(scheme_end) = subject.find("://") else {
2223 return false;
2224 };
2225 let authority_start = scheme_end + 3;
2226 let authority_end = subject[authority_start..]
2227 .find(['/', '?', '#'])
2228 .map_or(subject.len(), |offset| authority_start + offset);
2229 (authority_start..authority_end).contains(&at)
2230}
2231
2232fn parse_options(input: &str) -> Result<BTreeMap<String, String>> {
2233 if input.trim().is_empty() {
2234 return Ok(BTreeMap::new());
2235 }
2236 let mut options = BTreeMap::new();
2237 for entry in split_option_entries(input)? {
2238 let (raw_name, raw_value) = split_option_assignment(entry)?;
2239 let name = raw_name.trim();
2240 if !valid_option_name(name) {
2241 return Err(Error::config(format!("invalid option name `{name}`")));
2242 }
2243 let value = parse_option_value(raw_value)?;
2244 if options.insert(name.to_string(), value).is_some() {
2245 return Err(Error::config(format!("duplicate option `{name}`")));
2246 }
2247 }
2248 Ok(options)
2249}
2250
2251fn split_option_entries(input: &str) -> Result<Vec<&str>> {
2252 let mut entries = Vec::new();
2253 let mut start = 0;
2254 let mut quote = None;
2255 let mut escaped = false;
2256 for (index, character) in input.char_indices() {
2257 if escaped {
2258 escaped = false;
2259 continue;
2260 }
2261 if character == '\\' {
2262 escaped = true;
2263 continue;
2264 }
2265 if let Some(active) = quote {
2266 if character == active {
2267 quote = None;
2268 }
2269 } else if matches!(character, '\'' | '"') {
2270 quote = Some(character);
2271 } else if character == ',' {
2272 let entry = input[start..index].trim();
2273 if entry.is_empty() {
2274 return Err(Error::config("empty option entry"));
2275 }
2276 entries.push(entry);
2277 start = index + character.len_utf8();
2278 }
2279 }
2280 if quote.is_some() {
2281 return Err(Error::config("unterminated quoted option value"));
2282 }
2283 let entry = input[start..].trim();
2284 if entry.is_empty() {
2285 return Err(Error::config("empty option entry"));
2286 }
2287 entries.push(entry);
2288 Ok(entries)
2289}
2290
2291fn split_option_assignment(input: &str) -> Result<(&str, &str)> {
2292 let mut quote = None;
2293 let mut escaped = false;
2294 for (index, character) in input.char_indices() {
2295 if escaped {
2296 escaped = false;
2297 continue;
2298 }
2299 if character == '\\' {
2300 escaped = true;
2301 continue;
2302 }
2303 if let Some(active) = quote {
2304 if character == active {
2305 quote = None;
2306 }
2307 } else if matches!(character, '\'' | '"') {
2308 quote = Some(character);
2309 } else if character == '=' {
2310 return Ok((&input[..index], &input[index + 1..]));
2311 }
2312 }
2313 Err(Error::config(format!(
2314 "option entry must be `name=value`: `{input}`"
2315 )))
2316}
2317
2318fn valid_option_name(value: &str) -> bool {
2319 !value.is_empty()
2320 && value.chars().all(|character| {
2321 character.is_ascii_lowercase()
2322 || character.is_ascii_digit()
2323 || matches!(character, '-' | '_')
2324 })
2325}
2326
2327fn parse_option_value(input: &str) -> Result<String> {
2328 let input = input.trim();
2329 let decoded = if let Some(quote) = input
2330 .chars()
2331 .next()
2332 .filter(|character| matches!(character, '\'' | '"'))
2333 {
2334 if input.len() < 2 || !input.ends_with(quote) || escaped_final_quote(input) {
2335 return Err(Error::config("unterminated quoted option value"));
2336 }
2337 decode_escapes(
2338 &input[quote.len_utf8()..input.len() - quote.len_utf8()],
2339 Some(quote),
2340 )?
2341 } else {
2342 if input
2343 .chars()
2344 .any(|character| matches!(character, '\'' | '"'))
2345 {
2346 return Err(Error::config(
2347 "quotes must surround the complete option value",
2348 ));
2349 }
2350 decode_escapes(input, None)?
2351 };
2352 reject_control_characters(&decoded)?;
2353 Ok(decoded)
2354}
2355
2356fn escaped_final_quote(input: &str) -> bool {
2357 input[..input.len() - 1]
2358 .chars()
2359 .rev()
2360 .take_while(|character| *character == '\\')
2361 .count()
2362 % 2
2363 == 1
2364}
2365
2366fn decode_escapes(input: &str, quote: Option<char>) -> Result<String> {
2367 let mut decoded = String::with_capacity(input.len());
2368 let mut characters = input.chars();
2369 while let Some(character) = characters.next() {
2370 if character != '\\' {
2371 decoded.push(character);
2372 continue;
2373 }
2374 let Some(escaped) = characters.next() else {
2375 decoded.push('\\');
2376 break;
2377 };
2378 match escaped {
2379 '\\' => decoded.push('\\'),
2380 escaped if quote == Some(escaped) => decoded.push(escaped),
2381 '\'' | '"' | ',' | '[' | ']' | '=' if quote.is_none() => decoded.push(escaped),
2382 other => {
2383 decoded.push('\\');
2387 decoded.push(other);
2388 }
2389 }
2390 }
2391 Ok(decoded)
2392}
2393
2394fn write_option_value(formatter: &mut fmt::Formatter<'_>, value: &str) -> fmt::Result {
2395 let needs_quotes = value.is_empty()
2396 || value.trim() != value
2397 || value.chars().any(|character| {
2398 matches!(character, ',' | '[' | ']' | '=' | '\'' | '"' | '\\') || character.is_control()
2399 });
2400 if !needs_quotes {
2401 return formatter.write_str(value);
2402 }
2403 formatter.write_str("\"")?;
2404 for character in value.chars() {
2405 match character {
2406 '\\' => formatter.write_str("\\\\")?,
2407 '"' => formatter.write_str("\\\"")?,
2408 '\n' => formatter.write_str("\\n")?,
2409 '\r' => formatter.write_str("\\r")?,
2410 '\t' => formatter.write_str("\\t")?,
2411 other => formatter.write_str(&other.to_string())?,
2412 }
2413 }
2414 formatter.write_str("\"")
2415}
2416
2417fn invalid_request(input: &str) -> Error {
2418 Error::other(format!("invalid tool request `{input}`"))
2419}
2420
2421#[cfg(test)]
2422mod tests {
2423 use super::*;
2424
2425 #[test]
2426 fn fixed_and_dynamic_ids_have_one_canonical_form() {
2427 assert_eq!(ToolId::parse(" node ").unwrap().to_string(), "node");
2428 assert_eq!(
2429 ToolId::parse("npm:@Scope/Package_Name")
2430 .unwrap()
2431 .to_string(),
2432 "npm:@scope/package_name"
2433 );
2434 assert_eq!(
2435 ToolId::parse("github:Cli/CLI.git").unwrap().to_string(),
2436 "github:cli/cli"
2437 );
2438 }
2439
2440 #[test]
2441 fn npm_subject_is_lowercase_but_fixed_ids_are_not_global_lowercased() {
2442 assert_eq!(ToolId::parse("npm:Prettier").unwrap().subject(), "prettier");
2443 assert_eq!(ToolId::parse("CustomTool").unwrap().subject(), "CustomTool");
2444 }
2445
2446 #[test]
2447 fn parses_scoped_npm_and_github_selectors_without_ambiguity() {
2448 let npm = ToolSpec::parse("npm:@antfu/ni@0.21.12").unwrap();
2449 assert_eq!(npm.id.to_string(), "npm:@antfu/ni");
2450 assert_eq!(npm.selector(), Some("0.21.12"));
2451
2452 let github = ToolSpec::parse("github:Cli/CLI.git@v2.62.0").unwrap();
2453 assert_eq!(github.id.to_string(), "github:cli/cli");
2454 assert_eq!(github.selector(), Some("v2.62.0"));
2455 }
2456
2457 #[test]
2458 fn cargo_registry_subjects_and_selectors_are_canonical() {
2459 assert_eq!(
2460 ToolId::parse("cargo:Cargo_Edit").unwrap().to_string(),
2461 "cargo:cargo_edit"
2462 );
2463 assert_ne!(
2464 ToolId::parse("cargo:cargo_edit").unwrap(),
2465 ToolId::parse("cargo:cargo-edit").unwrap()
2466 );
2467
2468 for selector in ["latest", "14", "14.1", "14.1.0", "1.0.0-beta.1"] {
2469 let request = format!("cargo:ripgrep@{selector}");
2470 assert_eq!(
2471 ToolSpec::parse(&request).unwrap().selector(),
2472 Some(selector),
2473 "{request}"
2474 );
2475 }
2476 assert_eq!(ToolSpec::parse("cargo:ripgrep").unwrap().selector(), None);
2477
2478 let overlong = "a".repeat(65);
2479 for invalid in [
2480 "cargo:".to_string(),
2481 "cargo:1crate".to_string(),
2482 "cargo:-crate".to_string(),
2483 "cargo:crate-".to_string(),
2484 "cargo:foo/bar".to_string(),
2485 "cargo:foo.bar".to_string(),
2486 "cargo:CON".to_string(),
2487 format!("cargo:{overlong}"),
2488 ] {
2489 assert!(ToolSpec::parse(&invalid).is_err(), "{invalid}");
2490 }
2491 for invalid in [
2492 "cargo:ripgrep@",
2493 "cargo:ripgrep@v14.1.0",
2494 "cargo:ripgrep@14.1.0.0",
2495 "cargo:ripgrep@01",
2496 "cargo:ripgrep@^14",
2497 "cargo:ripgrep@14.*",
2498 "cargo:ripgrep@tag:v14",
2499 ] {
2500 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2501 }
2502 }
2503
2504 #[test]
2505 fn cargo_git_subjects_and_selectors_are_strict_and_unambiguous() {
2506 let revision = "0123456789abcdef0123456789abcdef01234567";
2507 for selector in [
2508 "latest",
2509 "tag:v1.2.3",
2510 "branch:release/1.x",
2511 &format!("rev:{revision}"),
2512 ] {
2513 let request = format!("cargo:https://git.example.test/Team/Tool.git@{selector}");
2514 let parsed = ToolSpec::parse(&request).unwrap();
2515 assert_eq!(
2516 parsed.id.to_string(),
2517 "cargo:https://git.example.test/Team/Tool.git"
2518 );
2519 assert_eq!(parsed.selector(), Some(selector), "{request}");
2520 }
2521 assert_eq!(
2522 ToolSpec::parse("cargo:https://git.example.test/Team/Tool.git")
2523 .unwrap()
2524 .selector(),
2525 None
2526 );
2527
2528 for invalid in [
2529 "cargo:http://git.example.test/team/tool.git@tag:v1",
2530 "cargo:git://git.example.test/team/tool.git@tag:v1",
2531 "cargo:file:///tmp/tool@branch:main",
2532 "cargo:https://user@git.example.test/team/tool.git",
2533 "cargo:https://git.example.test/team/tool.git?token=x@tag:v1",
2534 "cargo:https://git.example.test/team/tool.git#main@tag:v1",
2535 "cargo:https://git.example.test/team/../tool.git@tag:v1",
2536 "cargo:https://git.example.test/team/%2e%2e/tool.git@tag:v1",
2537 "cargo:https://git.example.test/team/%252e%252e/tool.git@tag:v1",
2538 "cargo:https://git.example.test/@tag:v1",
2539 "cargo:https://git.example.test/team/tool.git@1.2.3",
2540 "cargo:https://git.example.test/team/tool.git@tag:",
2541 "cargo:https://git.example.test/team/tool.git@branch:bad..ref",
2542 "cargo:https://git.example.test/team/tool.git@branch:bad.lock",
2543 "cargo:https://git.example.test/team/tool.git@rev:01234567",
2544 "cargo:https://git.example.test/team/tool.git@rev:0123456789ABCDEF0123456789ABCDEF01234567",
2545 "cargo:https://git.example.test/team/tool.git@0123456789abcdef0123456789abcdef01234567",
2546 ] {
2547 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2548 }
2549 }
2550
2551 #[test]
2552 fn go_module_and_command_paths_are_strict_and_case_preserving() {
2553 for value in [
2554 "go:example.com/acme/tool@1.2.3",
2555 "go:example.com/acme/tool@1.2.3-beta.1",
2556 "go:example.com/acme/tool/cmd/Tool@0.0.0-20240801123456-0123456789ab",
2557 "go:example.com/acme/tool@latest",
2558 "go:example.com/acme/tool@1",
2559 "go:example.com/acme/tool@1.2",
2560 ] {
2561 assert!(ToolSpec::parse(value).is_ok(), "{value}");
2562 }
2563 assert_eq!(
2564 ToolId::parse("go:example.com/Acme/Tool")
2565 .unwrap()
2566 .to_string(),
2567 "go:example.com/Acme/Tool"
2568 );
2569
2570 for invalid in [
2571 "go:",
2572 "go:example/acme",
2573 "go:Example.com/acme/tool",
2574 "go:example.com",
2575 "go:example.com//tool",
2576 "go:example.com/./tool",
2577 "go:example.com/../tool",
2578 "go:example.com/.hidden/tool",
2579 "go:example.com/acme./tool",
2580 "go:example.com/acme%2ftool",
2581 "go:example.com/acme\\tool",
2582 "go:example.com/acme/tool?x=1",
2583 "go:example.com/acme/tool#main",
2584 "go:example.com:443/acme/tool",
2585 "go:example.com/acme/tool@^1",
2586 "go:example.com/acme/tool@v1.2.3",
2587 "go:example.com/acme/tool@branch:main",
2588 "go:example.com/acme/tool@1.2.3+metadata",
2589 "go:example.com/acme/tool@0.0.0-2024080112345-0123456789ab",
2590 "go:example.com/acme/tool@0.0.0-20240801123456-0123456789aZ",
2591 "go:example.com/acme/tool@0.0.0-20241301123456-0123456789ab",
2592 "go:example.com/acme/tool@0.0.0-20240230123456-0123456789ab",
2593 ] {
2594 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2595 }
2596 assert!(ToolSpec::parse("go:example.com/acme/tool@1.2.3+incompatible").is_ok());
2597 assert!(ToolSpec::parse(
2598 "go:example.com/acme/tool@2.0.0-20240801123456-0123456789ab+incompatible"
2599 )
2600 .is_ok());
2601 }
2602
2603 #[test]
2604 fn go_tags_and_install_env_are_canonical_identity_options() {
2605 let parsed = ToolSpec::parse(
2606 "go:example.com/acme/tool[tags='sqlite,netgo,sqlite',env='GOAMD64=v3;CGO_ENABLED=0']@1.2.3",
2607 )
2608 .unwrap();
2609 assert_eq!(parsed.options.get("tags").unwrap(), "netgo,sqlite");
2610 assert_eq!(
2611 parsed.options.get("env").unwrap(),
2612 "CGO_ENABLED=0;GOAMD64=v3"
2613 );
2614 assert_eq!(
2615 dynamic_identity_options(&parsed.id, parsed.options.as_map())
2616 .unwrap()
2617 .into_map(),
2618 parsed.options.into_map()
2619 );
2620
2621 for invalid in [
2622 "go:example.com/acme/tool[tags=net-go]@1.2.3",
2623 "go:example.com/acme/tool[env=GOBIN=/tmp/bin]@1.2.3",
2624 "go:example.com/acme/tool[env=GOPROXY=https://user:secret@example.test]@1.2.3",
2625 "go:example.com/acme/tool[env=CGO_ENABLED=2]@1.2.3",
2626 "go:example.com/acme/tool[env=CGO_ENABLED=1]@1.2.3",
2627 "go:example.com/acme/tool[env=CGO_ENABLED=0;CGO_ENABLED=1]@1.2.3",
2628 ] {
2629 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2630 }
2631 }
2632
2633 #[test]
2634 fn cargo_options_are_canonical_safe_and_identity_bearing() {
2635 let parsed = ToolSpec::parse(
2636 "cargo:ripgrep[locked=true,features='simd, pcre2,simd',default-features=false,bin=rg]@14.1",
2637 )
2638 .unwrap();
2639 assert_eq!(
2640 parsed.to_string(),
2641 "cargo:ripgrep[bin=rg,default-features=false,features=\"pcre2,simd\",locked=true]@14.1"
2642 );
2643 assert_eq!(
2644 dynamic_identity_options(&parsed.id, parsed.options.as_map()).unwrap(),
2645 parsed.options
2646 );
2647
2648 let defaults =
2649 ToolSpec::parse("cargo:ripgrep[default-features=TRUE,locked=false]@latest").unwrap();
2650 assert!(defaults.options.is_empty());
2651
2652 let workspace = ToolSpec::parse(
2653 "cargo:https://git.example.test/team/workspace.git[crate=Rip_Grep]@tag:v1",
2654 )
2655 .unwrap();
2656 assert_eq!(workspace.options.get("crate").unwrap(), "rip_grep");
2657 let qualified =
2658 ToolSpec::parse("cargo:ripgrep[features='foo?/bar,dep:baz,plain,foo/bar,dep:baz']@14")
2659 .unwrap();
2660 assert_eq!(
2661 qualified.options.get("features").unwrap(),
2662 "dep:baz,foo/bar,foo?/bar,plain"
2663 );
2664
2665 for invalid in [
2666 "cargo:ripgrep[crate=ripgrep]@14",
2667 "cargo:ripgrep[features=]@14",
2668 "cargo:ripgrep[features='simd,,pcre2']@14",
2669 "cargo:ripgrep[features='dep:']@14",
2670 "cargo:ripgrep[features='foo?']@14",
2671 "cargo:ripgrep[features='foo//bar']@14",
2672 "cargo:ripgrep[features='../bar']@14",
2673 "cargo:ripgrep[features='foo/..']@14",
2674 "cargo:ripgrep[default-features=yes]@14",
2675 "cargo:ripgrep[locked=1]@14",
2676 "cargo:ripgrep[bin=../rg]@14",
2677 "cargo:ripgrep[bin=bin/rg]@14",
2678 "cargo:ripgrep[bin='rg.']@14",
2679 "cargo:ripgrep[bin=CON]@14",
2680 ] {
2681 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2682 }
2683 }
2684
2685 #[test]
2686 fn syntax_parser_preserves_url_userinfo_at_signs() {
2687 let parts =
2688 ToolSpecParts::parse("http:https://user@example.test/releases/tool.tar.gz@1.2.3")
2689 .unwrap();
2690 assert_eq!(
2691 parts.id,
2692 "http:https://user@example.test/releases/tool.tar.gz"
2693 );
2694 assert_eq!(parts.selector.as_deref(), Some("1.2.3"));
2695
2696 let without_selector =
2697 ToolSpecParts::parse("http:https://user@example.test/releases/tool.tar.gz").unwrap();
2698 assert_eq!(
2699 without_selector.id,
2700 "http:https://user@example.test/releases/tool.tar.gz"
2701 );
2702 assert_eq!(without_selector.selector, None);
2703 }
2704
2705 #[test]
2706 fn http_specs_require_strict_https_templates_exact_versions_and_checksums() {
2707 let digest = "A".repeat(64);
2708 let parsed = ToolSpec::parse(&format!(
2709 "http:https://downloads.example.test/tool-{{version}}.tar.gz[sha256={digest},kind=tar.gz,bin=pkg/tool,subdir=dist,rename=tool,strip-components=1]@1.2.3"
2710 ))
2711 .unwrap();
2712 assert_eq!(
2713 parsed.id.to_string(),
2714 "http:https://downloads.example.test/tool-{version}.tar.gz"
2715 );
2716 assert_eq!(parsed.selector(), Some("1.2.3"));
2717 assert_eq!(parsed.options.get("sha256").unwrap(), &"a".repeat(64));
2718 assert_eq!(parsed.options.get("bins").unwrap(), "pkg/tool");
2719
2720 for invalid in [
2721 "http:http://example.test/tool-{version}.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3",
2722 "http:https://user@example.test/tool-{version}.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3",
2723 "http:https://example.test/tool-{version}.zip?token=x[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3",
2724 "http:https://example.test/tool-{arch}.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3",
2725 "http:https://example.test/tool.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2.3",
2726 "http:https://example.test/tool-{version}.zip@1.2.3",
2727 "http:https://example.test/tool-{version}.zip[sha256=bad]@1.2.3",
2728 "http:https://example.test/tool-{version}.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@latest",
2729 "http:https://example.test/tool-{version}.zip[sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]@1.2",
2730 ] {
2731 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2732 }
2733 }
2734
2735 #[test]
2736 fn http_layout_options_are_canonical_and_cannot_escape() {
2737 let base = "http:https://example.test/tool-{version}.zip";
2738 let digest = "a".repeat(64);
2739 let singular = ToolSpec::parse(&format!(
2740 "{base}[sha256={digest},kind=zip,bin=dist/tool]@1.2.3"
2741 ))
2742 .unwrap();
2743 let plural = ToolSpec::parse(&format!(
2744 "{base}[kind=ZIP,bins=dist/tool,sha256={digest}]@1.2.3"
2745 ))
2746 .unwrap();
2747 assert_eq!(singular, plural);
2748 let inferred =
2749 ToolSpec::parse(&format!("{base}[sha256={digest},bin=dist/tool]@1.2.3")).unwrap();
2750 assert_eq!(inferred.options.get("bins").unwrap(), "dist/tool");
2751 assert!(ToolSpec::parse(&format!("{base}[sha256={digest}]@1.2.3")).is_err());
2752
2753 for option in [
2754 "bin=../tool",
2755 "bins=/tool",
2756 "subdir=../dist",
2757 "rename=../tool",
2758 "kind=tar.zst",
2759 "kind=file,bin=tool",
2760 "kind=zip,bin=a,bins=b",
2761 "kind=zip,bins=a,b,rename=tool",
2762 ] {
2763 let request = format!("{base}[sha256={digest},{option}]@1.2.3");
2764 assert!(ToolSpec::parse(&request).is_err(), "{request}");
2765 }
2766 }
2767
2768 #[test]
2769 fn http_templates_reject_noncanonical_paths_and_non_public_literals() {
2770 let digest = "a".repeat(64);
2771 for template in [
2772 "https://example.test/a/../tool-{version}.zip",
2773 "https://example.test/a/./tool-{version}.zip",
2774 "https://example.test/%2e%2e/tool-{version}.zip",
2775 "https://127.0.0.1/tool-{version}.zip",
2776 "https://169.254.169.254/tool-{version}.zip",
2777 "https://[::1]/tool-{version}.zip",
2778 "https://[::ffff:127.0.0.1]/tool-{version}.zip",
2779 "https://[::ffff:169.254.169.254]/tool-{version}.zip",
2780 ] {
2781 let request = format!("http:{template}[sha256={digest}]@1.2.3");
2782 assert!(ToolSpec::parse(&request).is_err(), "{request}");
2783 }
2784 assert!(is_public_ip("8.8.8.8".parse().unwrap()));
2785 assert!(is_public_ip("2606:4700:4700::1111".parse().unwrap()));
2786 assert!(!is_public_ip("::ffff:127.0.0.1".parse().unwrap()));
2787 assert!(!is_public_ip("::ffff:169.254.169.254".parse().unwrap()));
2788 }
2789
2790 #[test]
2791 fn inline_options_are_schema_validated_sorted_and_canonicalized() {
2792 let parsed =
2793 ToolSpec::parse("npm:Prettier[installer=AUBE,allow_builds='Sharp, esbuild, sharp']@3")
2794 .unwrap();
2795 assert_eq!(parsed.options.get("allow_builds").unwrap(), "esbuild,sharp");
2796 assert_eq!(parsed.options.get("installer").unwrap(), "aube");
2797 assert!(ToolSpec::parse("npm:prettier[allow_builds='../evil']@3").is_err());
2798 assert!(ToolSpec::parse("npm:prettier[allow_builds='@scope/AUX']@3").is_err());
2799 assert_eq!(
2800 parsed.to_string(),
2801 "npm:prettier[allow_builds=\"esbuild,sharp\",installer=aube]@3"
2802 );
2803 assert_eq!(ToolSpec::parse(&parsed.to_string()).unwrap(), parsed);
2804 }
2805
2806 #[test]
2807 fn quoted_and_escaped_values_round_trip() {
2808 let parsed = ToolSpec::parse(
2809 r#"github:owner/repo[asset-regex="^tool\[x\],v[0-9]+\.tgz$",asset-template=ignored]@latest"#,
2810 );
2811 assert!(parsed
2812 .unwrap_err()
2813 .to_string()
2814 .contains("mutually exclusive"));
2815
2816 let parsed =
2817 ToolSpec::parse(r#"github:owner/repo[asset-regex="^tool\[x\],v[0-9]+\.tgz$"]@latest"#)
2818 .unwrap();
2819 assert_eq!(
2820 parsed.options.get("asset-regex").unwrap(),
2821 r#"^tool\[x\],v[0-9]+\.tgz$"#
2822 );
2823 assert_eq!(ToolSpec::parse(&parsed.to_string()).unwrap(), parsed);
2824 }
2825
2826 #[test]
2827 fn github_aliases_and_platform_values_share_canonical_options() {
2828 let singular =
2829 ToolSpec::parse("github:Owner/Repo.git[bin=bin/tool,os=darwin,arch=amd64]@1").unwrap();
2830 let plural =
2831 ToolSpec::parse("github:owner/repo[bins=bin/tool,arch=x64,os=macos]@1").unwrap();
2832 assert_eq!(singular, plural);
2833 assert_eq!(
2834 singular.to_string(),
2835 "github:owner/repo[arch=x64,bins=bin/tool,os=macos]@1"
2836 );
2837 }
2838
2839 #[test]
2840 fn option_order_does_not_change_canonical_output() {
2841 let first =
2842 ToolSpec::parse("github:owner/repo[rename=rg,arch=amd64,os=darwin]@latest").unwrap();
2843 let second =
2844 ToolSpec::parse("github:OWNER/REPO[os=macos,rename=rg,arch=x64]@latest").unwrap();
2845 assert_eq!(first, second);
2846 assert_eq!(first.to_string(), second.to_string());
2847 }
2848
2849 #[test]
2850 fn catalog_location_is_validated_but_not_part_of_identity_projection() {
2851 let id = ToolId::parse("github:owner/repo").unwrap();
2852 let options = BTreeMap::from([
2853 (
2854 "catalog-url".into(),
2855 "https://example.test/catalog.json".into(),
2856 ),
2857 ("catalog-sha256".into(), "A".repeat(64)),
2858 ]);
2859 let canonical = canonicalize_dynamic_options(&id, &options).unwrap();
2860 assert!(canonical.get("catalog-url").is_some());
2861 let identity = dynamic_identity_options(&id, &options).unwrap();
2862 assert!(identity.get("catalog-url").is_none());
2863 assert_eq!(identity.get("catalog-sha256").unwrap(), &"a".repeat(64));
2864 }
2865
2866 #[test]
2867 fn unknown_namespaces_and_options_fail_during_schema_validation() {
2868 assert!(matches!(
2869 ToolSpec::parse("pip:ripgrep@latest"),
2870 Err(Error::UnknownBackend(_))
2871 ));
2872 let error = ToolSpec::parse("npm:prettier[token=secret]@3").unwrap_err();
2873 assert!(error.to_string().contains("unsupported option `token`"));
2874 let error = ToolSpec::parse("node[token=secret]@20").unwrap_err();
2875 assert!(error.to_string().contains("fixed backend"));
2876 }
2877
2878 #[test]
2879 fn private_replay_options_cannot_be_smuggled_through_inline_syntax() {
2880 let error = ToolSpec::parse("npm:prettier[__osdk_node_version=24]@3").unwrap_err();
2881 assert!(error.to_string().contains("internal option"));
2882 }
2883
2884 #[test]
2885 fn rejects_ambiguous_or_malformed_forms() {
2886 for invalid in [
2887 "",
2888 "@20",
2889 "npm:",
2890 "npm:@scope",
2891 "npm:foo[installer]",
2892 "npm:foo[=aube]",
2893 "npm:foo[installer=aube,]",
2894 "npm:foo[installer=aube,installer=npm]",
2895 "npm:foo[installer='aube]",
2896 "npm:foo[installer=aube]trailing@3",
2897 "github:owner/repo[bin=a,bins=b]@1",
2898 "github:owner/repo[asset-regex=x,asset-template=y]@1",
2899 ] {
2900 assert!(ToolSpec::parse(invalid).is_err(), "{invalid}");
2901 }
2902 }
2903
2904 #[test]
2905 fn install_identity_hashes_all_durable_selector_inputs() {
2906 let dependencies = vec![InstallDependency {
2907 kind: InstallDependencyKind::Runtime,
2908 id: "node".into(),
2909 version: "24.1.0".into(),
2910 identity: Some("runtime-id".into()),
2911 }];
2912 let materials = BTreeMap::from([("root-sri".into(), "sha512-one".into())]);
2913 let first = InstallIdentity::new(
2914 "npm:Prettier",
2915 "3.6.2",
2916 "linux-x64",
2917 InstallScope::Isolated,
2918 &BTreeMap::from([("installer".into(), "AUBE".into())]),
2919 dependencies.clone(),
2920 materials.clone(),
2921 )
2922 .unwrap();
2923 assert_eq!(first.tool, "npm:prettier");
2924 assert_eq!(first.material_options["installer"], "aube");
2925 assert!(first.install_id.starts_with("b3-v2:"));
2926 first.validate().unwrap();
2927
2928 let changed = InstallIdentity::new(
2929 "npm:prettier",
2930 "3.6.2",
2931 "linux-x64",
2932 InstallScope::Global,
2933 &BTreeMap::from([("installer".into(), "aube".into())]),
2934 dependencies,
2935 materials,
2936 )
2937 .unwrap();
2938 assert_ne!(first.install_id, changed.install_id);
2939 }
2940}