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.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                // C1: the per-artifact signing key is only trusted if the index
66                // that carried it was authenticated against a pinned root
67                // (transitive trust), or the key is itself pinned. Otherwise it
68                // is attacker-influenceable, so we drop it (`None`) — the install
69                // engine then treats the artifact as unsigned and, under a
70                // signature-requiring policy, refuses it (fail-closed).
71                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
106/// Pick the artifact for a specific platform out of a resolution.
107pub 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
118/// Whether a version entry satisfies a request's constraint.
119fn 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, // `VersionReq` is #[non_exhaustive]
129    }
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
150/// Order two version strings: SemVer where both parse, else lexical.
151fn 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(&reg);
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        // >=24, <25 → newest is 24.6.0
227        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    // C1: a registry index carries a per-tool `public_key`. It must only be
254    // propagated as a trusted signing key when the index was authenticated
255    // against a pinned root (or the key is itself pinned).
256    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        // Index NOT verified against a pinned root → the attacker-supplied
278        // signing key must not be trusted.
279        let reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
280        assert!(!reg.index_verified);
281        let resolver = Resolver::new(&reg);
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); // untrusted key rejected
287    }
288
289    #[test]
290    fn key_from_verified_index_is_trusted() {
291        // Index verified against a pinned root → its key is trusted transitively.
292        let mut reg = Registry::from_toml(SIGNED_SAMPLE).unwrap();
293        reg.index_verified = true;
294        let resolver = Resolver::new(&reg);
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}