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                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
90/// Pick the artifact for a specific platform out of a resolution.
91pub 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
102/// Whether a version entry satisfies a request's constraint.
103fn 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, // `VersionReq` is #[non_exhaustive]
113    }
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
134/// Order two version strings: SemVer where both parse, else lexical.
135fn 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(&reg);
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        // >=24, <25 → newest is 24.6.0
211        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}