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