Skip to main content

vanta_resolve/
lib.rs

1//! `vanta-resolve` — turn a [`Request`] into a deterministic [`Resolution`].
2//!
3//! Resolution reads the registry, filters versions by the request's constraint,
4//! picks the maximum satisfying version by SemVer ordering, and renders a
5//! per-platform artifact for every requested target. The result is fully pinned
6//! and lockable. See `docs/06-resolution.md`.
7//!
8//! Most tools are independent leaves; inter-tool dependency graphs are resolved
9//! where a provider declares them.
10#![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
16/// Resolves requests against a registry.
17pub 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    /// Resolve `request` for every platform in `targets`.
27    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                // C1: the per-artifact signing key is only trusted if the index
60                // that carried it was authenticated against a pinned root
61                // (transitive trust), or the key is itself pinned. Otherwise it
62                // is attacker-influenceable, so we drop it (`None`) — the install
63                // engine then treats the artifact as unsigned and, under a
64                // signature-requiring policy, refuses it (fail-closed).
65                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    /// Build the `unknown tool` error, enriched with close-match suggestions and
100    /// the list of tools the registry actually knows, so a typo or a
101    /// not-yet-supported tool produces an actionable message instead of a
102    /// dead end.
103    fn unknown_tool_error(&self, requested: &str) -> VtaError {
104        let names: Vec<&str> = self.registry.tools.keys().map(String::as_str).collect();
105
106        // "Did you mean": names within a small edit distance (scaled to the
107        // requested length) or that contain the request as a substring, best
108        // first, capped to a few.
109        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        // Tools people ask for that have no official prebuilt binaries: point
128        // at the canonical installer instead of a dead end.
129        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            // Cap the listing so a large future registry stays readable.
149            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    /// Build the `no version satisfies` error, listing the versions the registry
166    /// actually carries (newest first) so the user can pick a real one instead
167    /// of guessing. A stale index (e.g. asking for `24` when only `22`/`20` are
168    /// seeded) then reads as an obvious mismatch.
169    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)); // newest first
182
183        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
207/// Levenshtein edit distance between two strings (for "did you mean").
208fn 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
230/// Pick the artifact for a specific platform out of a resolution.
231pub 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
242/// Whether a version entry satisfies a request's constraint.
243fn 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, // `VersionReq` is #[non_exhaustive]
253    }
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
274/// Order two version strings: SemVer where both parse, else lexical.
275fn 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(&reg);
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        // >=24, <25 → newest is 24.6.0
351        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    // C1: a registry index carries a per-tool `public_key`. It must only be
378    // propagated as a trusted signing key when the index was authenticated
379    // against a pinned root (or the key is itself pinned).
380    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        // Index NOT verified against a pinned root → the attacker-supplied
402        // signing key must not be trusted.
403        let reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
404        assert!(!reg.index_verified);
405        let resolver = Resolver::new(&reg);
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); // untrusted key rejected
411    }
412
413    #[test]
414    fn key_from_verified_index_is_trusted() {
415        // Index verified against a pinned root → its key is trusted transitively.
416        let mut reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
417        reg.index_verified = true;
418        let resolver = Resolver::new(&reg);
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}