1#![forbid(unsafe_code)]
11
12use std::cmp::Ordering;
13use vanta_core::{Area, Checksum, Platform, Request, Resolution, VersionReq, VtaError, VtaResult};
14use vanta_registry::{Registry, VersionEntry};
15
16pub struct Resolver<'a> {
18 registry: &'a Registry,
19}
20
21impl<'a> Resolver<'a> {
22 pub fn new(registry: &'a Registry) -> Resolver<'a> {
23 Resolver { registry }
24 }
25
26 pub fn resolve(&self, request: &Request, targets: &[Platform]) -> VtaResult<Resolution> {
28 let entry = self.registry.tool(&request.tool).ok_or_else(|| {
29 VtaError::new(Area::Res, 3, format!("unknown tool `{}`", request.tool))
30 })?;
31
32 let candidates: Vec<&VersionEntry> = entry
33 .versions
34 .iter()
35 .filter(|v| !v.yanked && satisfies(&request.version, v))
36 .collect();
37
38 let chosen = candidates
39 .iter()
40 .copied()
41 .max_by(|a, b| cmp_version(&a.version, &b.version))
42 .ok_or_else(|| {
43 VtaError::new(
44 Area::Res,
45 1,
46 format!(
47 "no version of `{}` satisfies `{}`",
48 request.tool, request.version
49 ),
50 )
51 })?;
52
53 let mut per_platform = Vec::new();
54 for platform in targets {
55 if let Some(pc) = chosen.platforms.get(&platform.token()) {
56 let checksum = Checksum {
57 algo: "sha256".to_string(),
58 value: pc.sha256.clone(),
59 };
60 let mut artifact =
61 entry
62 .provider
63 .render_artifact(&chosen.version, platform, checksum, pc.size);
64 artifact.signature = pc.signature.clone();
65 artifact.signature_key = entry.public_key.clone();
66 per_platform.push((*platform, artifact));
67 }
68 }
69
70 if per_platform.is_empty() {
71 return Err(VtaError::new(
72 Area::Res,
73 5,
74 format!(
75 "no artifact for `{}` {} on any requested platform",
76 request.tool, chosen.version
77 ),
78 ));
79 }
80
81 Ok(Resolution {
82 tool: request.tool.clone(),
83 version: chosen.version.clone(),
84 provider: entry.provider.id.clone(),
85 per_platform,
86 })
87 }
88}
89
90pub fn artifact_for<'r>(
92 resolution: &'r Resolution,
93 platform: &Platform,
94) -> Option<&'r vanta_core::Artifact> {
95 resolution
96 .per_platform
97 .iter()
98 .find(|(p, _)| p == platform)
99 .map(|(_, a)| a)
100}
101
102fn satisfies(req: &VersionReq, entry: &VersionEntry) -> bool {
104 match req {
105 VersionReq::Exact(s) => &entry.version == s,
106 VersionReq::Prefix(p) => &entry.version == p || entry.version.starts_with(&format!("{p}.")),
107 VersionReq::Latest => is_stable(entry),
108 VersionReq::Lts => entry.lts,
109 VersionReq::Channel(c) => entry.channel.as_deref() == Some(c.as_str()),
110 VersionReq::Range(r) => sem_match(r, &entry.version),
111 VersionReq::System => false,
112 _ => false, }
114}
115
116fn is_stable(entry: &VersionEntry) -> bool {
117 let channel_ok = matches!(entry.channel.as_deref(), None | Some("stable"));
118 let pre_ok = semver::Version::parse(&entry.version)
119 .map(|v| v.pre.is_empty())
120 .unwrap_or(true);
121 channel_ok && pre_ok
122}
123
124fn sem_match(range: &str, version: &str) -> bool {
125 match (
126 semver::VersionReq::parse(range),
127 semver::Version::parse(version),
128 ) {
129 (Ok(req), Ok(ver)) => req.matches(&ver),
130 _ => false,
131 }
132}
133
134fn cmp_version(a: &str, b: &str) -> Ordering {
136 match (semver::Version::parse(a), semver::Version::parse(b)) {
137 (Ok(x), Ok(y)) => x.cmp(&y),
138 _ => a.cmp(b),
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use vanta_core::{Arch, Libc, Os};
146
147 const SAMPLE: &str = r#"
148[tools.node.provider]
149id = "official/node"
150tool = "node"
151url_template = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}"
152archive = "tar.gz"
153bin = ["bin/node"]
154
155[[tools.node.version]]
156version = "24.5.0"
157channel = "stable"
158[tools.node.version.platforms."macos/aarch64"]
159sha256 = "55"
160
161[[tools.node.version]]
162version = "24.6.0"
163channel = "stable"
164[tools.node.version.platforms."macos/aarch64"]
165sha256 = "66"
166
167[[tools.node.version]]
168version = "25.0.0"
169channel = "stable"
170[tools.node.version.platforms."macos/aarch64"]
171sha256 = "00"
172"#;
173
174 fn mac() -> Platform {
175 Platform {
176 os: Os::Macos,
177 arch: Arch::Aarch64,
178 libc: Libc::None,
179 }
180 }
181
182 fn resolve(spec: &str) -> VtaResult<Resolution> {
183 let reg = Registry::from_toml(SAMPLE).unwrap();
184 let resolver = Resolver::new(®);
185 resolver.resolve(&Request::parse(spec).unwrap(), &[mac()])
186 }
187
188 #[test]
189 fn prefix_picks_newest_in_series() {
190 assert_eq!(resolve("node@24").unwrap().version, "24.6.0");
191 }
192
193 #[test]
194 fn two_component_prefix_pins_series() {
195 assert_eq!(resolve("node@24.5").unwrap().version, "24.5.0");
196 }
197
198 #[test]
199 fn latest_picks_global_newest() {
200 assert_eq!(resolve("node@latest").unwrap().version, "25.0.0");
201 }
202
203 #[test]
204 fn exact_pins() {
205 assert_eq!(resolve("node@24.5.0").unwrap().version, "24.5.0");
206 }
207
208 #[test]
209 fn range_constraint() {
210 assert_eq!(resolve("node@>=24, <25").unwrap().version, "24.6.0");
212 }
213
214 #[test]
215 fn renders_artifact_for_target() {
216 let res = resolve("node@24").unwrap();
217 let art = artifact_for(&res, &mac()).unwrap();
218 assert_eq!(
219 art.url,
220 "https://nodejs.org/dist/v24.6.0/node-v24.6.0-macos-aarch64.tar.gz"
221 );
222 assert_eq!(art.checksum.value, "66");
223 }
224
225 #[test]
226 fn unknown_tool_errors() {
227 assert_eq!(resolve("python@3").unwrap_err().area, Area::Res);
228 }
229
230 #[test]
231 fn no_match_errors() {
232 let err = resolve("node@99").unwrap_err();
233 assert_eq!(err.area, Area::Res);
234 assert_eq!(err.number, 1);
235 }
236}