1use 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}