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
29 .registry
30 .tool(&request.tool)
31 .ok_or_else(|| self.unknown_tool_error(&request.tool))?;
32
33 let candidates: Vec<&VersionEntry> = entry
34 .versions
35 .iter()
36 .filter(|v| !v.yanked && satisfies(&request.version, v))
37 .collect();
38
39 let chosen = candidates
40 .iter()
41 .copied()
42 .max_by(|a, b| cmp_version(&a.version, &b.version))
43 .ok_or_else(|| {
44 self.no_matching_version_error(&request.tool, &request.version.to_string(), entry)
45 })?;
46
47 let mut per_platform = Vec::new();
48 for platform in targets {
49 if let Some(pc) = chosen.platforms.get(&platform.token()) {
50 let checksum = Checksum {
51 algo: "sha256".to_string(),
52 value: pc.sha256.clone(),
53 };
54 let mut artifact =
55 entry
56 .provider
57 .render_artifact(&chosen.version, platform, checksum, pc.size);
58 artifact.signature = pc.signature.clone();
59 artifact.signature_key = entry
66 .public_key
67 .as_deref()
68 .filter(|k| {
69 vanta_security::trust::artifact_key_is_trusted(
70 k,
71 self.registry.index_verified,
72 &self.registry.trusted_root_keys,
73 )
74 })
75 .map(str::to_string);
76 per_platform.push((*platform, artifact));
77 }
78 }
79
80 if per_platform.is_empty() {
81 return Err(VtaError::new(
82 Area::Res,
83 5,
84 format!(
85 "no artifact for `{}` {} on any requested platform",
86 request.tool, chosen.version
87 ),
88 ));
89 }
90
91 Ok(Resolution {
92 tool: request.tool.clone(),
93 version: chosen.version.clone(),
94 provider: entry.provider.id.clone(),
95 per_platform,
96 })
97 }
98
99 fn unknown_tool_error(&self, requested: &str) -> VtaError {
104 let names: Vec<&str> = self.registry.tools.keys().map(String::as_str).collect();
105
106 let want = requested.to_lowercase();
110 let budget = (want.len() / 3).max(2);
111 let mut scored: Vec<(usize, &str)> = names
112 .iter()
113 .filter_map(|n| {
114 let d = levenshtein(&want, &n.to_lowercase());
115 let contains = n.to_lowercase().contains(&want) || want.contains(&n.to_lowercase());
116 if d <= budget || contains {
117 Some((if contains { 0 } else { d }, *n))
118 } else {
119 None
120 }
121 })
122 .collect();
123 scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
124 let suggestions: Vec<&str> = scored.into_iter().take(3).map(|(_, n)| n).collect();
125
126 let mut msg = format!("unknown tool `{requested}`");
127 let hint = match requested {
130 "rust" | "rustc" | "cargo" => {
131 Some("rust is distributed via rustup (https://rustup.rs); a vanta source-build provider is planned")
132 }
133 "ruby" => Some(
134 "ruby is a source-build tool; update to a registry that includes it (needs a host C toolchain + openssl/libyaml/zlib)",
135 ),
136 "java" | "jdk" => Some("try `vanta search` for a packaged JDK, or use SDKMAN meanwhile"),
137 _ => None,
138 };
139 if let Some(h) = hint {
140 msg.push_str(&format!("\n note: {h}"));
141 }
142 if !suggestions.is_empty() {
143 msg.push_str(&format!("\n did you mean: {}?", suggestions.join(", ")));
144 }
145 if names.is_empty() {
146 msg.push_str("\n the registry index is empty (offline, or no registry configured)");
147 } else {
148 let shown = 20;
150 let list = names
151 .iter()
152 .take(shown)
153 .copied()
154 .collect::<Vec<_>>()
155 .join(", ");
156 msg.push_str(&format!("\n available tools: {list}"));
157 if names.len() > shown {
158 msg.push_str(&format!(", … (+{} more)", names.len() - shown));
159 }
160 msg.push_str("\n run `vanta search <term>` to search the registry");
161 }
162 VtaError::new(Area::Res, 3, msg)
163 }
164
165 fn no_matching_version_error(
170 &self,
171 tool: &str,
172 want: &str,
173 entry: &vanta_registry::ToolEntry,
174 ) -> VtaError {
175 let mut available: Vec<&str> = entry
176 .versions
177 .iter()
178 .filter(|v| !v.yanked)
179 .map(|v| v.version.as_str())
180 .collect();
181 available.sort_by(|a, b| cmp_version(b, a)); let mut msg = format!("no version of `{tool}` satisfies `{want}`");
184 if available.is_empty() {
185 msg.push_str("\n the registry lists no (non-yanked) versions for this tool");
186 } else {
187 let shown = 10;
188 let list = available
189 .iter()
190 .take(shown)
191 .copied()
192 .collect::<Vec<_>>()
193 .join(", ");
194 msg.push_str(&format!("\n available: {list}"));
195 if available.len() > shown {
196 msg.push_str(&format!(", … (+{} more)", available.len() - shown));
197 }
198 msg.push_str(&format!(
199 "\n try `vanta add {tool}@{}` or widen the constraint",
200 available[0]
201 ));
202 }
203 VtaError::new(Area::Res, 1, msg)
204 }
205}
206
207fn levenshtein(a: &str, b: &str) -> usize {
209 let a: Vec<char> = a.chars().collect();
210 let b: Vec<char> = b.chars().collect();
211 if a.is_empty() {
212 return b.len();
213 }
214 if b.is_empty() {
215 return a.len();
216 }
217 let mut prev: Vec<usize> = (0..=b.len()).collect();
218 let mut curr = vec![0usize; b.len() + 1];
219 for (i, ca) in a.iter().enumerate() {
220 curr[0] = i + 1;
221 for (j, cb) in b.iter().enumerate() {
222 let cost = if ca == cb { 0 } else { 1 };
223 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
224 }
225 std::mem::swap(&mut prev, &mut curr);
226 }
227 prev[b.len()]
228}
229
230pub fn artifact_for<'r>(
232 resolution: &'r Resolution,
233 platform: &Platform,
234) -> Option<&'r vanta_core::Artifact> {
235 resolution
236 .per_platform
237 .iter()
238 .find(|(p, _)| p == platform)
239 .map(|(_, a)| a)
240}
241
242fn satisfies(req: &VersionReq, entry: &VersionEntry) -> bool {
244 match req {
245 VersionReq::Exact(s) => &entry.version == s,
246 VersionReq::Prefix(p) => &entry.version == p || entry.version.starts_with(&format!("{p}.")),
247 VersionReq::Latest => is_stable(entry),
248 VersionReq::Lts => entry.lts,
249 VersionReq::Channel(c) => entry.channel.as_deref() == Some(c.as_str()),
250 VersionReq::Range(r) => sem_match(r, &entry.version),
251 VersionReq::System => false,
252 _ => false, }
254}
255
256fn is_stable(entry: &VersionEntry) -> bool {
257 let channel_ok = matches!(entry.channel.as_deref(), None | Some("stable"));
258 let pre_ok = semver::Version::parse(&entry.version)
259 .map(|v| v.pre.is_empty())
260 .unwrap_or(true);
261 channel_ok && pre_ok
262}
263
264fn sem_match(range: &str, version: &str) -> bool {
265 match (
266 semver::VersionReq::parse(range),
267 semver::Version::parse(version),
268 ) {
269 (Ok(req), Ok(ver)) => req.matches(&ver),
270 _ => false,
271 }
272}
273
274fn cmp_version(a: &str, b: &str) -> Ordering {
276 match (semver::Version::parse(a), semver::Version::parse(b)) {
277 (Ok(x), Ok(y)) => x.cmp(&y),
278 _ => a.cmp(b),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use vanta_core::{Arch, Libc, Os};
286
287 const SAMPLE: &str = r#"
288[tools.node.provider]
289id = "official/node"
290tool = "node"
291url_template = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}"
292archive = "tar.gz"
293bin = ["bin/node"]
294
295[[tools.node.version]]
296version = "24.5.0"
297channel = "stable"
298[tools.node.version.platforms."macos/aarch64"]
299sha256 = "55"
300
301[[tools.node.version]]
302version = "24.6.0"
303channel = "stable"
304[tools.node.version.platforms."macos/aarch64"]
305sha256 = "66"
306
307[[tools.node.version]]
308version = "25.0.0"
309channel = "stable"
310[tools.node.version.platforms."macos/aarch64"]
311sha256 = "00"
312"#;
313
314 fn mac() -> Platform {
315 Platform {
316 os: Os::Macos,
317 arch: Arch::Aarch64,
318 libc: Libc::None,
319 }
320 }
321
322 fn resolve(spec: &str) -> VtaResult<Resolution> {
323 let reg = Registry::from_toml(SAMPLE).unwrap();
324 let resolver = Resolver::new(®);
325 resolver.resolve(&Request::parse(spec).unwrap(), &[mac()])
326 }
327
328 #[test]
329 fn prefix_picks_newest_in_series() {
330 assert_eq!(resolve("node@24").unwrap().version, "24.6.0");
331 }
332
333 #[test]
334 fn two_component_prefix_pins_series() {
335 assert_eq!(resolve("node@24.5").unwrap().version, "24.5.0");
336 }
337
338 #[test]
339 fn latest_picks_global_newest() {
340 assert_eq!(resolve("node@latest").unwrap().version, "25.0.0");
341 }
342
343 #[test]
344 fn exact_pins() {
345 assert_eq!(resolve("node@24.5.0").unwrap().version, "24.5.0");
346 }
347
348 #[test]
349 fn range_constraint() {
350 assert_eq!(resolve("node@>=24, <25").unwrap().version, "24.6.0");
352 }
353
354 #[test]
355 fn renders_artifact_for_target() {
356 let res = resolve("node@24").unwrap();
357 let art = artifact_for(&res, &mac()).unwrap();
358 assert_eq!(
359 art.url,
360 "https://nodejs.org/dist/v24.6.0/node-v24.6.0-macos-aarch64.tar.gz"
361 );
362 assert_eq!(art.checksum.value, "66");
363 }
364
365 #[test]
366 fn unknown_tool_errors() {
367 assert_eq!(resolve("python@3").unwrap_err().area, Area::Res);
368 }
369
370 #[test]
371 fn no_match_errors() {
372 let err = resolve("node@99").unwrap_err();
373 assert_eq!(err.area, Area::Res);
374 assert_eq!(err.number, 1);
375 }
376
377 const SIGNED_SAMPLE: &str = r#"
381[tools.node]
382public_key = "untrusted comment: attacker\nRWQfattackerkeytext=="
383
384[tools.node.provider]
385id = "official/node"
386tool = "node"
387url_template = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}"
388archive = "tar.gz"
389bin = ["bin/node"]
390
391[[tools.node.version]]
392version = "24.6.0"
393channel = "stable"
394[tools.node.version.platforms."macos/aarch64"]
395sha256 = "66"
396signature = "untrusted comment: sig\nRWQfsig=="
397"#;
398
399 #[test]
400 fn attacker_key_with_unsigned_index_is_dropped() {
401 let reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
404 assert!(!reg.index_verified);
405 let resolver = Resolver::new(®);
406 let res = resolver
407 .resolve(&Request::parse("node@24.6.0").unwrap(), &[mac()])
408 .unwrap();
409 let art = artifact_for(&res, &mac()).unwrap();
410 assert_eq!(art.signature_key, None); }
412
413 #[test]
414 fn key_from_verified_index_is_trusted() {
415 let mut reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
417 reg.index_verified = true;
418 let resolver = Resolver::new(®);
419 let res = resolver
420 .resolve(&Request::parse("node@24.6.0").unwrap(), &[mac()])
421 .unwrap();
422 let art = artifact_for(&res, &mac()).unwrap();
423 assert!(art.signature_key.is_some());
424 }
425}