Skip to main content

lux_lib/
manifest_cache.rs

1//! Client for Lux's manifest cache.
2//!
3//! The cache precomputes the full transitive dependency closure including evaluated rockspecs.
4//! This lets Lux resolve a dependency graph without having to recursively fetch new rockspecs for
5//! all dependencies.
6//! Cache misses simply fall back to the regular discovery/building logic.
7
8use serde::{Deserialize, Deserializer};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11use url::Url;
12
13use crate::{
14    config::Config,
15    lua_version::LuaVersion,
16    luarocks,
17    package::{
18        PackageName, PackageReq, PackageVersion, RemotePackageType, RemotePackageTypeFilterSpec,
19    },
20};
21
22// cache.luanox.org may change its format over time, so we track it.
23const CACHE_FORMAT: u32 = 1;
24
25#[derive(Debug, Error)]
26pub(crate) enum ManifestCacheError {
27    #[error("rock not found in the manifest cache")]
28    NotFound,
29    #[error("unsupported manifest cache format {found} (expected {expected})")]
30    UnsupportedFormat { found: u32, expected: u32 },
31    #[error("failed to request the manifest cache")]
32    Request(#[source] reqwest::Error),
33    #[error("failed to parse the manifest cache response")]
34    Parse(#[source] serde_json::Error),
35    #[error("invalid manifest cache URL")]
36    Url(#[from] url::ParseError),
37}
38
39/// A precomputed dependency closure for one rock version.
40#[derive(Debug, Clone, Deserialize)]
41pub struct Closure {
42    format: u32,
43    specs: Vec<ClosureSpec>,
44}
45
46impl Closure {
47    pub(crate) fn spec(
48        &self,
49        req: &PackageReq,
50        filter: Option<&RemotePackageTypeFilterSpec>,
51    ) -> Option<(&ClosureSpec, RemotePackageType)> {
52        let default_filter = RemotePackageTypeFilterSpec::default();
53        let filter = filter.unwrap_or(&default_filter);
54        self.specs
55            .iter()
56            .filter(|spec| {
57                spec.package() == req.name() && req.version_req().matches(spec.version())
58            })
59            .filter_map(|spec| {
60                spec.remote_package_type(filter)
61                    .map(|package_type| (spec, package_type))
62            })
63            .max_by(|(spec_a, type_a), (spec_b, type_b)| {
64                spec_a
65                    .version()
66                    .cmp(spec_b.version())
67                    .then_with(|| type_a.cmp(type_b))
68            })
69    }
70}
71
72/// One entry of a precomputed closure.
73#[derive(Debug, Clone, Deserialize)]
74pub struct ClosureSpec {
75    package: PackageName,
76    version: PackageVersion,
77    #[serde(deserialize_with = "deserialize_available_types")]
78    available_types: Vec<RemotePackageType>,
79    rockspec_raw: String,
80}
81
82impl ClosureSpec {
83    pub(crate) fn package(&self) -> &PackageName {
84        &self.package
85    }
86
87    pub(crate) fn version(&self) -> &PackageVersion {
88        &self.version
89    }
90
91    pub(crate) fn rockspec_raw(&self) -> &str {
92        &self.rockspec_raw
93    }
94
95    /// The highest-priority package type available for this rock that is allowed
96    /// by `filter`.
97    pub(crate) fn remote_package_type(
98        &self,
99        filter: &RemotePackageTypeFilterSpec,
100    ) -> Option<RemotePackageType> {
101        self.available_types
102            .iter()
103            .filter(|&package_type| match package_type {
104                RemotePackageType::Rockspec => filter.rockspec,
105                RemotePackageType::Src => filter.src,
106                RemotePackageType::Binary => filter.binary,
107            })
108            .cloned()
109            .max()
110    }
111}
112
113#[derive(Debug, Clone, Deserialize)]
114struct VersionsIndex {
115    format: u32,
116    versions: Vec<PackageVersion>,
117}
118
119#[derive(Debug, Clone)]
120pub struct ManifestCache {
121    base_url: Url,
122    // Passed in the URL to fetch the right manifest version
123    lua_version: String,
124    client: reqwest::Client,
125}
126
127impl ManifestCache {
128    pub(crate) fn from_config(config: &Config) -> Option<Self> {
129        let base_url = config.manifest_cache_url()?.clone();
130        let lua_version = LuaVersion::from(config).ok()?.version_compatibility_str();
131        #[cfg(not(test))]
132        let client = crate::reqwest::https_client(config);
133        #[cfg(test)]
134        let client = crate::reqwest::http_client(config);
135        Some(Self {
136            base_url,
137            lua_version,
138            client: client.ok()?.clone(),
139        })
140    }
141
142    pub(crate) async fn versions(
143        &self,
144        name: &PackageName,
145    ) -> Result<Vec<PackageVersion>, ManifestCacheError> {
146        let url = self.url(&format!("versions/{name}.json"))?;
147        let index: VersionsIndex = self.get_json(&url).await?;
148        self.check_format(index.format)?;
149        Ok(index.versions)
150    }
151
152    pub(crate) async fn latest_closure(
153        &self,
154        name: &PackageName,
155    ) -> Result<Closure, ManifestCacheError> {
156        let url = self.url(&format!("latest/{name}.json"))?;
157        let closure: Closure = self.get_json(&url).await?;
158        self.check_format(closure.format)?;
159        Ok(closure)
160    }
161
162    pub(crate) async fn closure(
163        &self,
164        name: &PackageName,
165        version: &PackageVersion,
166    ) -> Result<Closure, ManifestCacheError> {
167        let hash = hex::encode(Sha256::digest(format!("{name}@{version}").as_bytes()));
168        let url = self.url(&format!("{hash}.json"))?;
169        let closure: Closure = self.get_json(&url).await?;
170        self.check_format(closure.format)?;
171        Ok(closure)
172    }
173
174    fn check_format(&self, format: u32) -> Result<(), ManifestCacheError> {
175        if format == CACHE_FORMAT {
176            Ok(())
177        } else {
178            Err(ManifestCacheError::UnsupportedFormat {
179                found: format,
180                expected: CACHE_FORMAT,
181            })
182        }
183    }
184
185    fn url(&self, path: &str) -> Result<Url, ManifestCacheError> {
186        Ok(self
187            .base_url
188            .join(&format!("{}/{}", self.lua_version, path))?)
189    }
190
191    async fn get_json<T>(&self, url: &Url) -> Result<T, ManifestCacheError>
192    where
193        T: serde::de::DeserializeOwned,
194    {
195        let response = self
196            .client
197            .get(url.clone())
198            .send()
199            .await
200            .map_err(ManifestCacheError::Request)?;
201
202        if response.status() == reqwest::StatusCode::NOT_FOUND {
203            return Err(ManifestCacheError::NotFound);
204        }
205
206        let response = response
207            .error_for_status()
208            .map_err(ManifestCacheError::Request)?;
209
210        let text = response.text().await.map_err(ManifestCacheError::Request)?;
211        serde_json::from_str(&text).map_err(ManifestCacheError::Parse)
212    }
213}
214
215fn deserialize_available_types<'de, D>(deserializer: D) -> Result<Vec<RemotePackageType>, D::Error>
216where
217    D: Deserializer<'de>,
218{
219    use RemotePackageType::{Binary, Rockspec, Src};
220
221    let arches = Vec::<String>::deserialize(deserializer)?;
222
223    Ok(arches
224        .into_iter()
225        .filter_map(|arch| match arch.as_str() {
226            "rockspec" => Some(Rockspec),
227            "src" => Some(Src),
228            "all" => Some(Binary),
229            arch if arch == luarocks::current_platform_luarocks_identifier().as_str() => {
230                Some(Binary)
231            }
232            _ => None,
233        })
234        .collect())
235}
236
237#[cfg(test)]
238mod tests {
239    use std::str::FromStr;
240
241    use super::*;
242
243    fn closure_from_json(json: &str) -> Closure {
244        serde_json::from_str(json).unwrap()
245    }
246
247    #[test]
248    fn selects_latest_matching_version() {
249        let closure = closure_from_json(
250            r#"{
251                "format": 1,
252                "specs": [
253                    {"package":"say","version":"1.0.0-1","available_types":["rockspec"],"rockspec_raw":""},
254                    {"package":"say","version":"2.0.0-1","available_types":["rockspec"],"rockspec_raw":""}
255                ]
256            }"#,
257        );
258        let req = PackageReq::from_str("say").unwrap();
259        let (spec, _) = closure.spec(&req, None).unwrap();
260        assert_eq!(
261            spec.version(),
262            &PackageVersion::from_str("2.0.0-1").unwrap()
263        );
264    }
265
266    #[test]
267    fn prefers_binary_over_rockspec_over_src() {
268        let req = PackageReq::from_str("say").unwrap();
269
270        let rockspec_or_src = closure_from_json(
271            r#"{"format":1,"specs":[{"package":"say","version":"1.0.0-1","available_types":["rockspec","src"],"rockspec_raw":""}]}"#,
272        );
273        assert_eq!(
274            rockspec_or_src.spec(&req, None).unwrap().1,
275            RemotePackageType::Rockspec
276        );
277
278        let binary = closure_from_json(
279            r#"{"format":1,"specs":[{"package":"say","version":"1.0.0-1","available_types":["all","rockspec"],"rockspec_raw":""}]}"#,
280        );
281        assert_eq!(
282            binary.spec(&req, None).unwrap().1,
283            RemotePackageType::Binary
284        );
285    }
286
287    #[test]
288    fn respects_type_filter() {
289        let closure = closure_from_json(
290            r#"{"format":1,"specs":[{"package":"say","version":"1.0.0-1","available_types":["all","rockspec"],"rockspec_raw":""}]}"#,
291        );
292        let req = PackageReq::from_str("say").unwrap();
293        let filter: RemotePackageTypeFilterSpec =
294            serde_json::from_str(r#"{"rockspec":true,"src":false,"binary":false}"#).unwrap();
295        assert_eq!(
296            closure.spec(&req, Some(&filter)).unwrap().1,
297            RemotePackageType::Rockspec
298        );
299    }
300
301    #[test]
302    fn version_constraint_filters_specs() {
303        let closure = closure_from_json(
304            r#"{"format":1,"specs":[{"package":"say","version":"1.0.0-1","available_types":["rockspec"],"rockspec_raw":""},{"package":"say","version":"2.0.0-1","available_types":["rockspec"],"rockspec_raw":""}]}"#,
305        );
306        let req = PackageReq::from_str("say < 2.0.0").unwrap();
307        let (spec, _) = closure.spec(&req, None).unwrap();
308        assert_eq!(
309            spec.version(),
310            &PackageVersion::from_str("1.0.0-1").unwrap()
311        );
312    }
313}