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
72 .public_key
73 .as_deref()
74 .filter(|k| {
75 vanta_security::trust::artifact_key_is_trusted(
76 k,
77 self.registry.index_verified,
78 &self.registry.trusted_root_keys,
79 )
80 })
81 .map(str::to_string);
82 per_platform.push((*platform, artifact));
83 }
84 }
85
86 if per_platform.is_empty() {
87 return Err(VtaError::new(
88 Area::Res,
89 5,
90 format!(
91 "no artifact for `{}` {} on any requested platform",
92 request.tool, chosen.version
93 ),
94 ));
95 }
96
97 Ok(Resolution {
98 tool: request.tool.clone(),
99 version: chosen.version.clone(),
100 provider: entry.provider.id.clone(),
101 per_platform,
102 })
103 }
104}
105
106pub fn artifact_for<'r>(
108 resolution: &'r Resolution,
109 platform: &Platform,
110) -> Option<&'r vanta_core::Artifact> {
111 resolution
112 .per_platform
113 .iter()
114 .find(|(p, _)| p == platform)
115 .map(|(_, a)| a)
116}
117
118fn satisfies(req: &VersionReq, entry: &VersionEntry) -> bool {
120 match req {
121 VersionReq::Exact(s) => &entry.version == s,
122 VersionReq::Prefix(p) => &entry.version == p || entry.version.starts_with(&format!("{p}.")),
123 VersionReq::Latest => is_stable(entry),
124 VersionReq::Lts => entry.lts,
125 VersionReq::Channel(c) => entry.channel.as_deref() == Some(c.as_str()),
126 VersionReq::Range(r) => sem_match(r, &entry.version),
127 VersionReq::System => false,
128 _ => false, }
130}
131
132fn is_stable(entry: &VersionEntry) -> bool {
133 let channel_ok = matches!(entry.channel.as_deref(), None | Some("stable"));
134 let pre_ok = semver::Version::parse(&entry.version)
135 .map(|v| v.pre.is_empty())
136 .unwrap_or(true);
137 channel_ok && pre_ok
138}
139
140fn sem_match(range: &str, version: &str) -> bool {
141 match (
142 semver::VersionReq::parse(range),
143 semver::Version::parse(version),
144 ) {
145 (Ok(req), Ok(ver)) => req.matches(&ver),
146 _ => false,
147 }
148}
149
150fn cmp_version(a: &str, b: &str) -> Ordering {
152 match (semver::Version::parse(a), semver::Version::parse(b)) {
153 (Ok(x), Ok(y)) => x.cmp(&y),
154 _ => a.cmp(b),
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use vanta_core::{Arch, Libc, Os};
162
163 const SAMPLE: &str = r#"
164[tools.node.provider]
165id = "official/node"
166tool = "node"
167url_template = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}"
168archive = "tar.gz"
169bin = ["bin/node"]
170
171[[tools.node.version]]
172version = "24.5.0"
173channel = "stable"
174[tools.node.version.platforms."macos/aarch64"]
175sha256 = "55"
176
177[[tools.node.version]]
178version = "24.6.0"
179channel = "stable"
180[tools.node.version.platforms."macos/aarch64"]
181sha256 = "66"
182
183[[tools.node.version]]
184version = "25.0.0"
185channel = "stable"
186[tools.node.version.platforms."macos/aarch64"]
187sha256 = "00"
188"#;
189
190 fn mac() -> Platform {
191 Platform {
192 os: Os::Macos,
193 arch: Arch::Aarch64,
194 libc: Libc::None,
195 }
196 }
197
198 fn resolve(spec: &str) -> VtaResult<Resolution> {
199 let reg = Registry::from_toml(SAMPLE).unwrap();
200 let resolver = Resolver::new(®);
201 resolver.resolve(&Request::parse(spec).unwrap(), &[mac()])
202 }
203
204 #[test]
205 fn prefix_picks_newest_in_series() {
206 assert_eq!(resolve("node@24").unwrap().version, "24.6.0");
207 }
208
209 #[test]
210 fn two_component_prefix_pins_series() {
211 assert_eq!(resolve("node@24.5").unwrap().version, "24.5.0");
212 }
213
214 #[test]
215 fn latest_picks_global_newest() {
216 assert_eq!(resolve("node@latest").unwrap().version, "25.0.0");
217 }
218
219 #[test]
220 fn exact_pins() {
221 assert_eq!(resolve("node@24.5.0").unwrap().version, "24.5.0");
222 }
223
224 #[test]
225 fn range_constraint() {
226 assert_eq!(resolve("node@>=24, <25").unwrap().version, "24.6.0");
228 }
229
230 #[test]
231 fn renders_artifact_for_target() {
232 let res = resolve("node@24").unwrap();
233 let art = artifact_for(&res, &mac()).unwrap();
234 assert_eq!(
235 art.url,
236 "https://nodejs.org/dist/v24.6.0/node-v24.6.0-macos-aarch64.tar.gz"
237 );
238 assert_eq!(art.checksum.value, "66");
239 }
240
241 #[test]
242 fn unknown_tool_errors() {
243 assert_eq!(resolve("python@3").unwrap_err().area, Area::Res);
244 }
245
246 #[test]
247 fn no_match_errors() {
248 let err = resolve("node@99").unwrap_err();
249 assert_eq!(err.area, Area::Res);
250 assert_eq!(err.number, 1);
251 }
252
253 const SIGNED_SAMPLE: &str = r#"
257[tools.node]
258public_key = "untrusted comment: attacker\nRWQfattackerkeytext=="
259
260[tools.node.provider]
261id = "official/node"
262tool = "node"
263url_template = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.{ext}"
264archive = "tar.gz"
265bin = ["bin/node"]
266
267[[tools.node.version]]
268version = "24.6.0"
269channel = "stable"
270[tools.node.version.platforms."macos/aarch64"]
271sha256 = "66"
272signature = "untrusted comment: sig\nRWQfsig=="
273"#;
274
275 #[test]
276 fn attacker_key_with_unsigned_index_is_dropped() {
277 let reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
280 assert!(!reg.index_verified);
281 let resolver = Resolver::new(®);
282 let res = resolver
283 .resolve(&Request::parse("node@24.6.0").unwrap(), &[mac()])
284 .unwrap();
285 let art = artifact_for(&res, &mac()).unwrap();
286 assert_eq!(art.signature_key, None); }
288
289 #[test]
290 fn key_from_verified_index_is_trusted() {
291 let mut reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
293 reg.index_verified = true;
294 let resolver = Resolver::new(®);
295 let res = resolver
296 .resolve(&Request::parse("node@24.6.0").unwrap(), &[mac()])
297 .unwrap();
298 let art = artifact_for(&res, &mac()).unwrap();
299 assert!(art.signature_key.is_some());
300 }
301}