Skip to main content

pulith_resource/
lib.rs

1//! Composable resource description types for Pulith.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use pulith_version::{
7    SelectionPolicy, VersionKind, VersionPreference, VersionRequirement, select_preferred,
8};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use url::Url;
12
13pub type Labels = BTreeMap<String, String>;
14pub type Metadata = BTreeMap<String, String>;
15
16pub type Result<T> = std::result::Result<T, ResourceError>;
17
18#[derive(Debug, Error, Clone, PartialEq, Eq)]
19pub enum ResourceError {
20    #[error("resource authority must not be empty")]
21    EmptyAuthority,
22    #[error("resource name must not be empty")]
23    EmptyName,
24    #[error("invalid resource segment `{0}`")]
25    InvalidSegment(String),
26    #[error("invalid URL: {0}")]
27    InvalidUrl(String),
28    #[error("digest hex is invalid: {0}")]
29    InvalidDigestHex(String),
30    #[error("digest length for {algorithm:?} must be {expected} bytes, got {actual}")]
31    InvalidDigestLength {
32        algorithm: DigestAlgorithm,
33        expected: usize,
34        actual: usize,
35    },
36    #[error("value must not be empty")]
37    EmptyValue,
38    #[error("alternatives must not be empty")]
39    EmptyAlternatives,
40    #[error("trust anchor host must not be empty")]
41    EmptyTrustHost,
42    #[error("trust metadata key must not be empty")]
43    EmptyTrustMetadataKey,
44    #[error("resolved version is not parseable for selector matching: {0}")]
45    InvalidResolvedVersion(String),
46    #[error("resolved version `{version}` does not satisfy selector `{selector}`")]
47    ResolvedVersionMismatch { selector: String, version: String },
48    #[error("version alias `{0}` is not recognized")]
49    UnknownVersionAlias(String),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
53pub struct ResourceId {
54    pub authority: Option<String>,
55    pub name: String,
56}
57
58impl ResourceId {
59    pub fn new(authority: Option<impl Into<String>>, name: impl Into<String>) -> Result<Self> {
60        let authority = authority.map(Into::into);
61        let name = name.into();
62
63        if let Some(authority) = &authority {
64            if authority.is_empty() {
65                return Err(ResourceError::EmptyAuthority);
66            }
67            validate_segments(authority)?;
68        }
69
70        if name.is_empty() {
71            return Err(ResourceError::EmptyName);
72        }
73        validate_segments(&name)?;
74
75        Ok(Self { authority, name })
76    }
77
78    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
79        let value = value.as_ref();
80        if let Some((authority, name)) = value.rsplit_once('/') {
81            Self::new(Some(authority.to_string()), name.to_string())
82        } else {
83            Self::new(None::<String>, value.to_string())
84        }
85    }
86
87    pub fn as_string(&self) -> String {
88        match &self.authority {
89            Some(authority) => format!("{authority}/{}", self.name),
90            None => self.name.clone(),
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(transparent)]
97pub struct ValidUrl(Url);
98
99impl ValidUrl {
100    pub fn parse(value: impl AsRef<str>) -> Result<Self> {
101        let parsed =
102            Url::parse(value.as_ref()).map_err(|err| ResourceError::InvalidUrl(err.to_string()))?;
103        Ok(Self(parsed))
104    }
105
106    pub fn as_url(&self) -> &Url {
107        &self.0
108    }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub enum VersionSelector {
113    Exact(VersionKind),
114    Alias(String),
115    Requirement(VersionRequirement),
116    Unspecified,
117}
118
119impl VersionSelector {
120    pub fn exact(value: impl Into<String>) -> Result<Self> {
121        Ok(Self::Exact(parse_non_empty_value(
122            value,
123            VersionKind::parse,
124        )?))
125    }
126
127    pub fn alias(value: impl Into<String>) -> Result<Self> {
128        Ok(Self::Alias(non_empty_string(value)?))
129    }
130
131    pub fn requirement(value: impl Into<String>) -> Result<Self> {
132        Ok(Self::Requirement(parse_non_empty_value(
133            value,
134            VersionRequirement::parse,
135        )?))
136    }
137
138    pub fn matches_resolved_version(&self, version: &ResolvedVersion) -> Result<bool> {
139        let resolved = match self {
140            Self::Exact(_) | Self::Requirement(_) => Some(parse_resolved_version(version)?),
141            Self::Alias(_) | Self::Unspecified => None,
142        };
143
144        match self {
145            Self::Exact(expected) => Ok(Some(expected) == resolved.as_ref()),
146            Self::Requirement(requirement) => {
147                Ok(resolved.is_some_and(|resolved| requirement.matches(&resolved)))
148            }
149            Self::Alias(_) | Self::Unspecified => Ok(true),
150        }
151    }
152
153    pub fn as_label(&self) -> String {
154        match self {
155            Self::Exact(version) => version.to_string(),
156            Self::Alias(alias) => alias.clone(),
157            Self::Requirement(requirement) => format!("{requirement:?}"),
158            Self::Unspecified => "*".to_string(),
159        }
160    }
161
162    pub fn selection_policy(&self) -> Result<SelectionPolicy> {
163        match self {
164            Self::Exact(version) => Ok(selection_policy(
165                VersionRequirement::Exact(version.clone()),
166                VersionPreference::Pinned(version.clone()),
167            )),
168            Self::Alias(alias) => alias_selection_policy(alias),
169            Self::Requirement(requirement) => Ok(selection_policy(
170                requirement.clone(),
171                VersionPreference::HighestStable,
172            )),
173            Self::Unspecified => Ok(SelectionPolicy::default()),
174        }
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ResolvedVersion(String);
180
181impl ResolvedVersion {
182    pub fn new(value: impl Into<String>) -> Result<Self> {
183        let value = value.into();
184        ensure_non_empty(&value)?;
185        Ok(Self(value))
186    }
187
188    pub fn as_str(&self) -> &str {
189        &self.0
190    }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub enum ResourceLocator {
195    Url(ValidUrl),
196    Alternatives(Vec<ValidUrl>),
197    LocalPath(PathBuf),
198}
199
200impl ResourceLocator {
201    pub fn alternatives(urls: Vec<ValidUrl>) -> Result<Self> {
202        ensure_non_empty_collection(&urls, ResourceError::EmptyAlternatives)?;
203        Ok(Self::Alternatives(urls))
204    }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub enum ResolvedLocator {
209    Url(ValidUrl),
210    LocalPath(PathBuf),
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub enum DigestAlgorithm {
215    Sha256,
216    Blake3,
217    Custom(String),
218}
219
220impl DigestAlgorithm {
221    fn expected_length(&self) -> Option<usize> {
222        match self {
223            Self::Sha256 | Self::Blake3 => Some(32),
224            Self::Custom(_) => None,
225        }
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct ValidDigest {
231    pub algorithm: DigestAlgorithm,
232    pub bytes: Vec<u8>,
233}
234
235impl ValidDigest {
236    pub fn from_bytes(algorithm: DigestAlgorithm, bytes: Vec<u8>) -> Result<Self> {
237        if let Some(expected) = algorithm.expected_length()
238            && bytes.len() != expected
239        {
240            return Err(ResourceError::InvalidDigestLength {
241                algorithm,
242                expected,
243                actual: bytes.len(),
244            });
245        }
246
247        Ok(Self { algorithm, bytes })
248    }
249
250    pub fn from_hex(algorithm: DigestAlgorithm, value: impl AsRef<str>) -> Result<Self> {
251        let bytes = hex::decode(value.as_ref())
252            .map_err(|err| ResourceError::InvalidDigestHex(err.to_string()))?;
253        Self::from_bytes(algorithm, bytes)
254    }
255
256    pub fn hex(&self) -> String {
257        hex::encode(&self.bytes)
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub enum VerificationRequirement {
263    None,
264    Digest(ValidDigest),
265    AnyOf(Vec<ValidDigest>),
266    AllOf(Vec<ValidDigest>),
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub enum TrustMode {
271    Open,
272    RequireVerification,
273    RequireAnchorMatch,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub enum TrustAnchor {
278    Digest(ValidDigest),
279    Host(String),
280    Metadata { key: String, value: String },
281}
282
283impl TrustAnchor {
284    pub fn host(value: impl Into<String>) -> Result<Self> {
285        let value = value.into();
286        ensure_non_empty(&value).map_err(|_| ResourceError::EmptyTrustHost)?;
287        Ok(Self::Host(value))
288    }
289
290    pub fn metadata(key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
291        let key = key.into();
292        let value = value.into();
293        ensure_non_empty(&key).map_err(|_| ResourceError::EmptyTrustMetadataKey)?;
294        ensure_non_empty(&value)?;
295        Ok(Self::Metadata { key, value })
296    }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub struct TrustPolicy {
301    pub mode: TrustMode,
302    pub anchors: Vec<TrustAnchor>,
303}
304
305impl Default for TrustPolicy {
306    fn default() -> Self {
307        Self {
308            mode: TrustMode::Open,
309            anchors: Vec::new(),
310        }
311    }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum TrustDecision {
316    Trusted,
317    Untrusted(&'static str),
318}
319
320impl TrustPolicy {
321    pub fn evaluate(
322        &self,
323        locator: Option<&ResolvedLocator>,
324        artifact: Option<&ArtifactDescriptor>,
325        metadata: &Metadata,
326        verification: &VerificationRequirement,
327    ) -> TrustDecision {
328        match self.mode {
329            TrustMode::Open => TrustDecision::Trusted,
330            TrustMode::RequireVerification => match verification {
331                VerificationRequirement::None => TrustDecision::Untrusted("verification required"),
332                _ => TrustDecision::Trusted,
333            },
334            TrustMode::RequireAnchorMatch => {
335                if self
336                    .anchors
337                    .iter()
338                    .any(|anchor| anchor_matches(anchor, locator, artifact, metadata))
339                {
340                    TrustDecision::Trusted
341                } else {
342                    TrustDecision::Untrusted("no trust anchor matched")
343                }
344            }
345        }
346    }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub enum ArtifactForm {
351    File,
352    Archive,
353    DirectorySnapshot,
354    Opaque,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub enum UnpackPolicy {
359    None,
360    Extract { strip_components: usize },
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct MaterializationSpec {
365    pub form: ArtifactForm,
366    pub unpack: UnpackPolicy,
367}
368
369impl Default for MaterializationSpec {
370    fn default() -> Self {
371        Self {
372            form: ArtifactForm::Opaque,
373            unpack: UnpackPolicy::None,
374        }
375    }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct ArtifactDescriptor {
380    pub digest: Option<ValidDigest>,
381    pub file_name: Option<String>,
382    pub metadata: Metadata,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386pub struct ResourceSpec {
387    pub id: ResourceId,
388    pub locator: ResourceLocator,
389    pub version: VersionSelector,
390    pub verification: VerificationRequirement,
391    pub trust: TrustPolicy,
392    pub materialization: MaterializationSpec,
393    pub labels: Labels,
394    pub metadata: Metadata,
395}
396
397impl ResourceSpec {
398    pub fn new(id: ResourceId, locator: ResourceLocator) -> Self {
399        Self {
400            id,
401            locator,
402            version: VersionSelector::Unspecified,
403            verification: VerificationRequirement::None,
404            trust: TrustPolicy::default(),
405            materialization: MaterializationSpec::default(),
406            labels: Labels::new(),
407            metadata: Metadata::new(),
408        }
409    }
410
411    pub fn version(mut self, version: VersionSelector) -> Self {
412        self.version = version;
413        self
414    }
415
416    pub fn verification(mut self, verification: VerificationRequirement) -> Self {
417        self.verification = verification;
418        self
419    }
420
421    pub fn trust(mut self, trust: TrustPolicy) -> Self {
422        self.trust = trust;
423        self
424    }
425
426    pub fn materialization(mut self, materialization: MaterializationSpec) -> Self {
427        self.materialization = materialization;
428        self
429    }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct Requested;
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct Resolved {
437    pub version: ResolvedVersion,
438    pub locator: ResolvedLocator,
439    pub artifact: Option<ArtifactDescriptor>,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct Resource<S> {
444    spec: ResourceSpec,
445    state: S,
446}
447
448pub type RequestedResource = Resource<Requested>;
449pub type ResolvedResource = Resource<Resolved>;
450
451impl RequestedResource {
452    pub fn new(spec: ResourceSpec) -> Self {
453        Self {
454            spec,
455            state: Requested,
456        }
457    }
458
459    pub fn resolve(
460        self,
461        version: ResolvedVersion,
462        locator: ResolvedLocator,
463        artifact: Option<ArtifactDescriptor>,
464    ) -> ResolvedResource {
465        ResolvedResource {
466            spec: self.spec,
467            state: Resolved {
468                version,
469                locator,
470                artifact,
471            },
472        }
473    }
474}
475
476impl<S> Resource<S> {
477    pub fn spec(&self) -> &ResourceSpec {
478        &self.spec
479    }
480
481    pub fn into_spec(self) -> ResourceSpec {
482        self.spec
483    }
484
485    pub fn version_selection_policy(&self) -> Result<SelectionPolicy> {
486        self.spec.version.selection_policy()
487    }
488
489    pub fn select_preferred_resolved<'a>(
490        &self,
491        candidates: &'a [ResolvedResource],
492    ) -> Result<Option<&'a ResolvedResource>> {
493        let policy = self.version_selection_policy()?;
494        let parsed_versions = candidates
495            .iter()
496            .filter(|candidate| candidate.spec().id == self.spec.id)
497            .map(|candidate| {
498                parse_resolved_version(candidate.version()).map(|version| (candidate, version))
499            })
500            .collect::<Result<Vec<_>>>()?;
501
502        let versions = parsed_versions
503            .iter()
504            .map(|(_, version)| version.clone())
505            .collect::<Vec<_>>();
506        let Some(selected) = select_preferred(&versions, &policy) else {
507            return Ok(None);
508        };
509
510        Ok(parsed_versions
511            .into_iter()
512            .find_map(|(candidate, version)| (&version == selected).then_some(candidate)))
513    }
514}
515
516impl ResolvedResource {
517    pub fn resolved(&self) -> &Resolved {
518        &self.state
519    }
520
521    pub fn version(&self) -> &ResolvedVersion {
522        &self.state.version
523    }
524
525    pub fn locator(&self) -> &ResolvedLocator {
526        &self.state.locator
527    }
528
529    pub fn trust_decision(&self) -> TrustDecision {
530        self.spec.trust.evaluate(
531            Some(&self.state.locator),
532            self.state.artifact.as_ref(),
533            &self.spec.metadata,
534            &self.spec.verification,
535        )
536    }
537
538    pub fn validate_version_selection(&self) -> Result<()> {
539        if !self
540            .spec
541            .version
542            .matches_resolved_version(&self.state.version)?
543        {
544            return Err(ResourceError::ResolvedVersionMismatch {
545                selector: self.spec.version.as_label(),
546                version: self.state.version.as_str().to_string(),
547            });
548        }
549
550        Ok(())
551    }
552}
553
554fn parse_resolved_version(version: &ResolvedVersion) -> Result<VersionKind> {
555    VersionKind::parse(version.as_str())
556        .map_err(|_| ResourceError::InvalidResolvedVersion(version.as_str().to_string()))
557}
558
559fn alias_selection_policy(alias: &str) -> Result<SelectionPolicy> {
560    let preference = match alias.to_ascii_lowercase().as_str() {
561        "latest" => VersionPreference::Latest,
562        "lowest" => VersionPreference::Lowest,
563        "stable" => VersionPreference::HighestStable,
564        "lts" => VersionPreference::Lts,
565        _ => return Err(ResourceError::UnknownVersionAlias(alias.to_string())),
566    };
567
568    Ok(selection_policy(VersionRequirement::Any, preference))
569}
570
571fn selection_policy(
572    requirement: VersionRequirement,
573    preference: VersionPreference,
574) -> SelectionPolicy {
575    SelectionPolicy {
576        requirement,
577        preference,
578    }
579}
580
581fn anchor_matches(
582    anchor: &TrustAnchor,
583    locator: Option<&ResolvedLocator>,
584    artifact: Option<&ArtifactDescriptor>,
585    metadata: &Metadata,
586) -> bool {
587    match anchor {
588        TrustAnchor::Digest(expected) => artifact
589            .and_then(|artifact| artifact.digest.as_ref())
590            .is_some_and(|digest| digest == expected),
591        TrustAnchor::Host(host) => locator
592            .and_then(|locator| match locator {
593                ResolvedLocator::Url(url) => url.as_url().host_str(),
594                ResolvedLocator::LocalPath(_) => None,
595            })
596            .is_some_and(|value| value == host),
597        TrustAnchor::Metadata { key, value } => {
598            metadata.get(key).is_some_and(|found| found == value)
599        }
600    }
601}
602
603fn ensure_non_empty(value: &str) -> Result<()> {
604    if value.is_empty() {
605        Err(ResourceError::EmptyValue)
606    } else {
607        Ok(())
608    }
609}
610
611fn non_empty_string(value: impl Into<String>) -> Result<String> {
612    let value = value.into();
613    ensure_non_empty(&value)?;
614    Ok(value)
615}
616
617fn parse_non_empty_value<T, F, E>(value: impl Into<String>, parse: F) -> Result<T>
618where
619    F: FnOnce(&str) -> std::result::Result<T, E>,
620{
621    let value = non_empty_string(value)?;
622    parse(&value).map_err(|_| ResourceError::EmptyValue)
623}
624
625fn ensure_non_empty_collection<T>(values: &[T], error: ResourceError) -> Result<()> {
626    if values.is_empty() {
627        Err(error)
628    } else {
629        Ok(())
630    }
631}
632
633fn validate_segments(value: &str) -> Result<()> {
634    for segment in value.split('/') {
635        if segment.is_empty()
636            || !segment
637                .chars()
638                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
639        {
640            return Err(ResourceError::InvalidSegment(segment.to_string()));
641        }
642    }
643    Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn resource_id_parses_authority_and_name() {
652        let id = ResourceId::parse("github.com/neovim/nvim").unwrap();
653        assert_eq!(id.authority.as_deref(), Some("github.com/neovim"));
654        assert_eq!(id.name, "nvim");
655    }
656
657    #[test]
658    fn url_and_digest_validation_work() {
659        let url = ValidUrl::parse("https://example.com/tool.tar.gz").unwrap();
660        assert_eq!(url.as_url().scheme(), "https");
661
662        let digest = ValidDigest::from_hex(
663            DigestAlgorithm::Sha256,
664            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
665        )
666        .unwrap();
667        assert_eq!(digest.bytes.len(), 32);
668    }
669
670    #[test]
671    fn requested_resource_can_resolve() {
672        let spec = ResourceSpec::new(
673            ResourceId::parse("nodejs.org/node").unwrap(),
674            ResourceLocator::Url(ValidUrl::parse("https://example.com/node.zip").unwrap()),
675        )
676        .version(VersionSelector::alias("lts").unwrap())
677        .materialization(MaterializationSpec {
678            form: ArtifactForm::Archive,
679            unpack: UnpackPolicy::Extract {
680                strip_components: 1,
681            },
682        });
683
684        let requested = RequestedResource::new(spec);
685        let resolved = requested.resolve(
686            ResolvedVersion::new("20.12.1").unwrap(),
687            ResolvedLocator::Url(ValidUrl::parse("https://mirror.example.com/node.zip").unwrap()),
688            None,
689        );
690
691        assert_eq!(resolved.version().as_str(), "20.12.1");
692        assert!(resolved.validate_version_selection().is_ok());
693    }
694
695    #[test]
696    fn resolved_resource_rejects_requirement_mismatch() {
697        let spec = ResourceSpec::new(
698            ResourceId::parse("nodejs.org/node").unwrap(),
699            ResourceLocator::Url(ValidUrl::parse("https://example.com/node.zip").unwrap()),
700        )
701        .version(VersionSelector::requirement("^1.2").unwrap());
702
703        let resolved = RequestedResource::new(spec).resolve(
704            ResolvedVersion::new("2.0.0").unwrap(),
705            ResolvedLocator::Url(ValidUrl::parse("https://mirror.example.com/node.zip").unwrap()),
706            None,
707        );
708
709        assert!(matches!(
710            resolved.validate_version_selection(),
711            Err(ResourceError::ResolvedVersionMismatch { .. })
712        ));
713    }
714
715    #[test]
716    fn version_selector_exact_maps_to_pinned_policy() {
717        let selector = VersionSelector::exact("1.2.3").unwrap();
718        let policy = selector.selection_policy().unwrap();
719
720        assert_eq!(
721            policy,
722            SelectionPolicy {
723                requirement: VersionRequirement::Exact(VersionKind::parse("1.2.3").unwrap()),
724                preference: VersionPreference::Pinned(VersionKind::parse("1.2.3").unwrap()),
725            }
726        );
727    }
728
729    #[test]
730    fn version_selector_requirement_prefers_highest_stable() {
731        let selector = VersionSelector::requirement("^1.2").unwrap();
732        let policy = selector.selection_policy().unwrap();
733
734        assert_eq!(
735            policy.requirement,
736            VersionRequirement::parse("^1.2").unwrap()
737        );
738        assert_eq!(policy.preference, VersionPreference::HighestStable);
739    }
740
741    #[test]
742    fn version_selector_alias_maps_common_preferences() {
743        assert_eq!(
744            VersionSelector::alias("latest")
745                .unwrap()
746                .selection_policy()
747                .unwrap()
748                .preference,
749            VersionPreference::Latest
750        );
751        assert_eq!(
752            VersionSelector::alias("stable")
753                .unwrap()
754                .selection_policy()
755                .unwrap()
756                .preference,
757            VersionPreference::HighestStable
758        );
759        assert_eq!(
760            VersionSelector::alias("lts")
761                .unwrap()
762                .selection_policy()
763                .unwrap()
764                .preference,
765            VersionPreference::Lts
766        );
767    }
768
769    #[test]
770    fn version_selector_rejects_unknown_alias_for_selection_policy() {
771        assert!(matches!(
772            VersionSelector::alias("canary").unwrap().selection_policy(),
773            Err(ResourceError::UnknownVersionAlias(alias)) if alias == "canary"
774        ));
775    }
776
777    #[test]
778    fn resource_exposes_version_selection_policy() {
779        let resource = RequestedResource::new(
780            ResourceSpec::new(
781                ResourceId::parse("nodejs.org/node").unwrap(),
782                ResourceLocator::Url(ValidUrl::parse("https://example.com/node.zip").unwrap()),
783            )
784            .version(VersionSelector::alias("stable").unwrap()),
785        );
786
787        let policy = resource.version_selection_policy().unwrap();
788        assert_eq!(policy.preference, VersionPreference::HighestStable);
789    }
790
791    #[test]
792    fn resource_can_select_preferred_resolved_candidate() {
793        let resource = RequestedResource::new(
794            ResourceSpec::new(
795                ResourceId::parse("nodejs.org/node").unwrap(),
796                ResourceLocator::Url(ValidUrl::parse("https://example.com/node.zip").unwrap()),
797            )
798            .version(VersionSelector::alias("lts").unwrap()),
799        );
800        let candidates = vec![
801            RequestedResource::new(ResourceSpec::new(
802                ResourceId::parse("nodejs.org/node").unwrap(),
803                ResourceLocator::Url(ValidUrl::parse("https://example.com/node-20.zip").unwrap()),
804            ))
805            .resolve(
806                ResolvedVersion::new("20.11.0").unwrap(),
807                ResolvedLocator::Url(ValidUrl::parse("https://example.com/node-20.zip").unwrap()),
808                None,
809            ),
810            RequestedResource::new(ResourceSpec::new(
811                ResourceId::parse("nodejs.org/node").unwrap(),
812                ResourceLocator::Url(ValidUrl::parse("https://example.com/node-22.zip").unwrap()),
813            ))
814            .resolve(
815                ResolvedVersion::new("22.4.0").unwrap(),
816                ResolvedLocator::Url(ValidUrl::parse("https://example.com/node-22.zip").unwrap()),
817                None,
818            ),
819        ];
820
821        let selected = resource
822            .select_preferred_resolved(&candidates)
823            .unwrap()
824            .unwrap();
825        assert_eq!(selected.version().as_str(), "22.4.0");
826    }
827
828    #[test]
829    fn trust_policy_can_require_anchor_match() {
830        let digest = ValidDigest::from_hex(
831            DigestAlgorithm::Sha256,
832            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
833        )
834        .unwrap();
835
836        let spec = ResourceSpec::new(
837            ResourceId::parse("nodejs.org/node").unwrap(),
838            ResourceLocator::Url(
839                ValidUrl::parse("https://downloads.example.com/node.zip").unwrap(),
840            ),
841        )
842        .verification(VerificationRequirement::Digest(digest.clone()))
843        .trust(TrustPolicy {
844            mode: TrustMode::RequireAnchorMatch,
845            anchors: vec![TrustAnchor::host("downloads.example.com").unwrap()],
846        });
847
848        let requested = RequestedResource::new(spec);
849        let resolved = requested.resolve(
850            ResolvedVersion::new("20.12.1").unwrap(),
851            ResolvedLocator::Url(
852                ValidUrl::parse("https://downloads.example.com/node.zip").unwrap(),
853            ),
854            Some(ArtifactDescriptor {
855                digest: Some(digest),
856                file_name: Some("node.zip".to_string()),
857                metadata: Metadata::new(),
858            }),
859        );
860
861        assert_eq!(resolved.trust_decision(), TrustDecision::Trusted);
862    }
863}