Skip to main content

osdk_core/
tool.rs

1//! Canonical tool identities and the shared dynamic request grammar.
2//!
3//! A tool id is either a fixed backend name (`node`) or a namespaced dynamic
4//! identity (`npm:prettier`).  Dynamic namespaces own both subject
5//! canonicalization and their public option schema so parsing, inventory, and
6//! fingerprint callers can share one definition of identity.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15/// A canonical backend identity.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum ToolId {
18    /// A compiled-in or declarative backend with no dynamic subject.
19    Fixed(String),
20    /// A backend selected by a namespace-specific subject.
21    Dynamic { namespace: String, subject: String },
22}
23
24impl ToolId {
25    /// Parse and canonicalize an identity without options or a selector.
26    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    /// The fixed backend name or dynamic namespace subject.
59    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/// Installation scope participating in a dynamic install's durable identity.
91#[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/// Kind of an exact dependency captured by an installation identity.
100#[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/// An exact dependency whose bytes or behavior contribute to an install.
109#[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/// Complete, reproducible identity of one materialized dynamic installation.
120///
121/// Collections are canonicalized before hashing and serialized in their stable
122/// order. `install_id` is a domain-separated BLAKE3 digest of every preceding
123/// field and is therefore suitable for selecting an on-disk install root. Only
124/// inputs known before publication belong here: post-install observations and
125/// verification evidence belong in receipts, never in locator identity.
126#[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    /// Validate canonical persisted fields and the self-authenticating id.
181    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/// Which lifecycle boundary an option can change.
241#[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/// One accepted spelling in a namespace's option schema.
251#[derive(Clone, Copy)]
252pub struct OptionDefinition {
253    pub name: &'static str,
254    pub canonical_name: &'static str,
255    pub effect: OptionEffect,
256    /// Whether the canonical value contributes to install identity.
257    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/// The accepted options and cross-option validation for one namespace.
277#[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    /// Canonicalize a request option map. Internal lock replay metadata is
295    /// ignored deliberately; it is not a public namespace option.
296    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    /// Validate an already-canonical identity projection without applying a
330    /// second normalization. Acquisition-only and internal keys are invalid.
331    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    /// Canonical public options that contribute to installation identity.
358    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/// A sorted, namespace-validated option map.
379#[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/// Subject and option rules for a registered dynamic namespace.
422#[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/// The syntactic pieces of `tool[options]@selector`, before namespace schema
457/// validation. This is useful to future namespaces whose subjects contain `@`.
458#[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/// A fully canonical parsed tool expression.
472#[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
541/// Return the schema for a registered namespace. Namespace names are already
542/// canonical and intentionally case-sensitive at this boundary.
543pub 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
554/// Canonicalize a dynamic id through the registered namespace subject rules.
555pub 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
565/// Validate and canonicalize all public request options for a dynamic id.
566pub 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
575/// Derive the safe, canonical install-identity projection for a dynamic id.
576pub 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
585/// Validate an identity projection loaded from durable state.
586pub 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
596/// Validate a selector that has already been split from a canonical dynamic
597/// backend id. Durable config and lock readers use this instead of rebuilding
598/// an ambiguously delimited request string.
599pub 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    // The required digest identifies catalog content. Persisting its location
661    // would leak acquisition metadata without strengthening reuse identity.
662    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
1277/// Conservative public-unicast policy shared by HTTP template validation and
1278/// the network-time resolver. Rejecting special-use ranges is preferable to
1279/// letting an artifact URL reach local, link-local, documentation, transition,
1280/// or metadata-service address space.
1281pub(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        // Enabling cgo would make the host C compiler and linker part of the
1527        // artifact identity. Until osdk can select and bind that toolchain,
1528        // accept only the reproducible pure-Go mode.
1529        "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                // Regexes commonly use backslash escapes unknown to this
2384                // grammar. Preserve those two bytes rather than changing the
2385                // backend-visible expression.
2386                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}