wasmer_wasix/runtime/resolver/
local_registry_source.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Error};
4use itertools::Itertools;
5use semver::Version;
6use wasmer_config::package::{
7    NamedPackageId, NamedPackageIdent, PackageHash, PackageId, PackageIdent, PackageSource,
8};
9
10use crate::runtime::resolver::{PackageSummary, QueryError, Source, WebcHash};
11
12/// A [`Source`] backed by a directory tree laid out like a registry:
13/// `<root>/<namespace>/<name>/<version>.webc`, or `<root>/<name>/<version>.webc`
14/// for un-namespaced packages.
15///
16/// Queries are answered from the layout alone: a named query lists the queried
17/// package's directory and picks versions off the file names, so only the webcs
18/// that match the version constraint are ever opened. A large tree costs no
19/// more than the packages actually resolved.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct LocalRegistrySource {
22    root: PathBuf,
23}
24
25impl LocalRegistrySource {
26    pub fn new(root: impl Into<PathBuf>) -> Result<Self, Error> {
27        let root = root.into();
28        // Fail fast if the directory doesn't exist rather than
29        // leaving all packages to resolve to 'not found'
30        anyhow::ensure!(
31            root.is_dir(),
32            "package directory does not exist: \"{}\"",
33            root.display()
34        );
35        Ok(LocalRegistrySource { root })
36    }
37
38    fn query_named(
39        &self,
40        named: &NamedPackageIdent,
41        query: &PackageSource,
42    ) -> Result<Vec<PackageSummary>, QueryError> {
43        let full_name = named.full_name();
44        // A name that doesn't map into the layout can't be published in it.
45        let Some(dir) = package_dir_path(&self.root, &full_name) else {
46            return Err(QueryError::NotFound {
47                query: query.clone(),
48            });
49        };
50        // No directory on disk means no published versions.
51        if !dir.is_dir() {
52            return Err(QueryError::NotFound {
53                query: query.clone(),
54            });
55        }
56
57        let constraint = named.version_or_default();
58        let matches: Vec<_> = published_versions(&dir)
59            .map_err(|error| QueryError::new_other(error, query))?
60            .into_iter()
61            .filter(|(version, _)| constraint.matches(version))
62            .sorted_by(|(left, _), (right, _)| left.cmp_precedence(right))
63            .collect();
64
65        if matches.is_empty() {
66            return Err(QueryError::NoMatches {
67                query: query.clone(),
68                archived_versions: Vec::new(),
69            });
70        }
71
72        // Only now is any webc opened, and only the matching ones.
73        matches
74            .into_iter()
75            .map(|(version, path)| {
76                let id = NamedPackageId {
77                    full_name: full_name.clone(),
78                    version,
79                };
80                load_summary(&path, Some(PackageId::Named(id)))
81            })
82            .collect::<Result<Vec<_>, Error>>()
83            .map_err(|error| QueryError::new_other(error, query))
84    }
85
86    fn query_hash(
87        &self,
88        hash: &PackageHash,
89        query: &PackageSource,
90    ) -> Result<Vec<PackageSummary>, QueryError> {
91        match self
92            .find_by_hash(hash)
93            .map_err(|error| QueryError::new_other(error, query))?
94        {
95            Some(summary) => Ok(vec![summary]),
96            None => Err(QueryError::NotFound {
97                query: query.clone(),
98            }),
99        }
100    }
101
102    /// Walk the tree for a webc with this hash, stopping at the first match.
103    /// This is the one query shape the layout can't index. The `.sha256`
104    /// sidecars rule files in or out without opening them, so they are all
105    /// consulted first; only if none matched are the sidecar-less webcs
106    /// hashed.
107    fn find_by_hash(&self, hash: &PackageHash) -> Result<Option<PackageSummary>, Error> {
108        let Some(expected) = hash.as_sha256().map(|digest| digest.to_string()) else {
109            return Ok(None);
110        };
111
112        let load_match = |webc: &Path| -> Result<PackageSummary, Error> {
113            let id = id_from_path(&self.root, webc).map(PackageId::Named);
114            let summary = load_summary(webc, id)?;
115            // A sidecar may match the query and still misdescribe the file.
116            verify_sha256(webc, &summary.dist.webc_sha256, &expected)?;
117            Ok(summary)
118        };
119
120        let mut unclaimed = Vec::new();
121        for entry in walkdir::WalkDir::new(&self.root).follow_links(true) {
122            let entry = entry.context("Unable to walk the package directory")?;
123            let path = entry.path();
124            if !entry.file_type().is_file()
125                || path.extension().and_then(|e| e.to_str()) != Some("webc")
126            {
127                continue;
128            }
129            match read_sha256_sibling(path) {
130                Some(claimed) => {
131                    let claimed = claimed.strip_prefix("sha256:").unwrap_or(&claimed);
132                    if claimed.eq_ignore_ascii_case(&expected) {
133                        return load_match(path).map(Some);
134                    }
135                }
136                None => unclaimed.push(path.to_path_buf()),
137            }
138        }
139
140        // No sidecar matched; hash the webcs that don't have one.
141        for path in unclaimed {
142            let actual = WebcHash::for_file(&path)
143                .with_context(|| format!("Unable to hash \"{}\"", path.display()))?;
144            if actual.as_hex().eq_ignore_ascii_case(&expected) {
145                return load_match(&path).map(Some);
146            }
147        }
148
149        Ok(None)
150    }
151}
152
153#[async_trait::async_trait]
154impl Source for LocalRegistrySource {
155    #[tracing::instrument(level = "debug", skip_all, fields(%package))]
156    async fn query(&self, package: &PackageSource) -> Result<Vec<PackageSummary>, QueryError> {
157        match package {
158            PackageSource::Ident(PackageIdent::Named(named)) => {
159                crate::block_in_place(|| self.query_named(named, package))
160            }
161            PackageSource::Ident(PackageIdent::Hash(hash)) => {
162                crate::block_in_place(|| self.query_hash(hash, package))
163            }
164            PackageSource::Url(_) | PackageSource::Path(_) => Err(QueryError::Unsupported {
165                query: package.clone(),
166            }),
167        }
168    }
169}
170
171/// Where the layout would keep a package's published versions: the full
172/// name's components (namespace, then name) become path components under
173/// `root`. Only builds the path — existence is the caller's question.
174/// `None` for names the layout can't hold (empty or path-like components),
175/// rather than letting them escape the root.
176fn package_dir_path(root: &Path, full_name: &str) -> Option<PathBuf> {
177    let mut dir = root.to_path_buf();
178    for part in full_name.split('/') {
179        if part.is_empty() || part == "." || part == ".." || part.contains(std::path::is_separator)
180        {
181            return None;
182        }
183        dir.push(part);
184    }
185    Some(dir)
186}
187
188/// The versions published for one package: its directory's `<version>.webc`
189/// files, read from the names alone. No webc is opened.
190fn published_versions(dir: &Path) -> Result<Vec<(Version, PathBuf)>, Error> {
191    let mut versions = Vec::new();
192    for entry in dir
193        .read_dir()
194        .with_context(|| format!("Unable to read \"{}\"", dir.display()))?
195    {
196        let path = entry?.path();
197        let Some(version) = path
198            .file_name()
199            .and_then(|f| f.to_str())
200            .and_then(|f| f.strip_suffix(".webc"))
201            .and_then(|stem| stem.parse().ok())
202        else {
203            continue;
204        };
205        versions.push((version, path));
206    }
207    Ok(versions)
208}
209
210/// The package id encoded by a webc's location under `root`
211/// (`<namespace>/<name>/<version>.webc` or `<name>/<version>.webc`), if it
212/// fits the layout. Used where a walk finds a file and its id must be derived
213/// backwards; named lookups go the other way via [`package_dir_path`].
214fn id_from_path(root: &Path, webc: &Path) -> Option<NamedPackageId> {
215    let rel = webc.strip_prefix(root).ok()?;
216    let parts = rel.iter().map(|p| p.to_str()).collect::<Option<Vec<_>>>()?;
217    let (full_name, file) = match parts.as_slice() {
218        [namespace, name, file] => (format!("{namespace}/{name}"), *file),
219        [name, file] => (name.to_string(), *file),
220        _ => return None,
221    };
222    let version = file.strip_suffix(".webc")?.parse().ok()?;
223    Some(NamedPackageId { full_name, version })
224}
225
226/// Open one matched webc and build its summary. The layout's id (when given)
227/// wins over the manifest's; a `<stem>.sha256` sibling, if present, must match
228/// the content.
229fn load_summary(webc: &Path, id: Option<PackageId>) -> Result<PackageSummary, Error> {
230    let mut summary = PackageSummary::from_webc_file(webc)
231        .with_context(|| format!("Unable to load \"{}\"", webc.display()))?;
232    if let Some(expected) = read_sha256_sibling(webc) {
233        verify_sha256(webc, &summary.dist.webc_sha256, &expected)?;
234    }
235    if let Some(id) = id {
236        summary.pkg.id = id;
237    }
238    Ok(summary)
239}
240
241/// The trimmed digest of an optional `<stem>.sha256` sibling, if present.
242fn read_sha256_sibling(webc: &Path) -> Option<String> {
243    let contents = std::fs::read_to_string(webc.with_extension("sha256")).ok()?;
244    Some(contents.trim().to_string())
245}
246
247/// Error if `actual` doesn't match `expected` (hex, an optional `sha256:`
248/// prefix allowed).
249fn verify_sha256(webc: &Path, actual: &WebcHash, expected: &str) -> Result<(), Error> {
250    let expected = expected.trim();
251    let expected = expected.strip_prefix("sha256:").unwrap_or(expected);
252    if !expected.eq_ignore_ascii_case(&actual.as_hex()) {
253        anyhow::bail!(
254            "sha256 mismatch for \"{}\": expected {expected}, file hashes to {}",
255            webc.display(),
256            actual.as_hex(),
257        );
258    }
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use tempfile::TempDir;
265
266    use super::*;
267
268    const COREUTILS_16: &[u8] = include_bytes!(concat!(
269        env!("CARGO_MANIFEST_DIR"),
270        "/../../wasmer-test-files/integration/webc/coreutils-1.0.16-e27dbb4f-2ef2-4b44-b46a-ddd86497c6d7.webc"
271    ));
272    const COREUTILS_11: &[u8] = include_bytes!(concat!(
273        env!("CARGO_MANIFEST_DIR"),
274        "/../../wasmer-test-files/integration/webc/coreutils-1.0.11-9d7746ca-694f-11ed-b932-dead3543c068.webc"
275    ));
276
277    fn registry(files: &[(&str, &[u8])]) -> TempDir {
278        let temp = TempDir::new().unwrap();
279        for (rel, bytes) in files {
280            let path = temp.path().join(rel);
281            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
282            std::fs::write(path, bytes).unwrap();
283        }
284        temp
285    }
286
287    fn named(id: &str, version: &str) -> PackageId {
288        PackageId::Named(NamedPackageId::try_new(id, version).unwrap())
289    }
290
291    /// Run [`Source::query`] synchronously via block_on.
292    /// The implementation does no async work currently,
293    /// the only reason it's async is due to the trait forcing it.
294    fn query(
295        source: &LocalRegistrySource,
296        package: &PackageSource,
297    ) -> Result<Vec<PackageSummary>, QueryError> {
298        futures::executor::block_on(source.query(package))
299    }
300
301    #[test]
302    fn new_rejects_a_missing_directory() {
303        let temp = TempDir::new().unwrap();
304        assert!(LocalRegistrySource::new(temp.path().join("nope")).is_err());
305        assert!(LocalRegistrySource::new(temp.path()).is_ok());
306    }
307
308    #[test]
309    fn named_query_ids_come_from_the_layout() {
310        // COREUTILS_16's manifest is "sharrattj/coreutils@1.0.16"; the layout
311        // must win, proving resolution keys off the path, not the artifact.
312        let temp = registry(&[("acme/cutils/9.9.9.webc", COREUTILS_16)]);
313        let source = LocalRegistrySource::new(temp.path()).unwrap();
314
315        let summaries = query(&source, &"acme/cutils".parse().unwrap()).unwrap();
316
317        assert_eq!(summaries.len(), 1);
318        assert_eq!(summaries[0].pkg.id, named("acme/cutils", "9.9.9"));
319        // The metadata itself still comes from the webc.
320        assert!(!summaries[0].pkg.commands.is_empty());
321    }
322
323    #[test]
324    fn only_matching_versions_are_opened() {
325        // 0.1.0 is garbage; a constraint that rules it out never reads it.
326        let temp = registry(&[
327            ("acme/cutils/0.1.0.webc", b"not a webc".as_slice()),
328            ("acme/cutils/9.9.9.webc", COREUTILS_16),
329        ]);
330        let source = LocalRegistrySource::new(temp.path()).unwrap();
331
332        let summaries = query(&source, &"acme/cutils@9.9.9".parse().unwrap()).unwrap();
333        assert_eq!(summaries[0].pkg.id, named("acme/cutils", "9.9.9"));
334
335        // ...while a constraint that pulls it in surfaces the corruption.
336        assert!(matches!(
337            query(&source, &"acme/cutils".parse().unwrap()),
338            Err(QueryError::Other { .. })
339        ));
340    }
341
342    #[test]
343    fn version_constraints_filter_the_listing() {
344        let temp = registry(&[
345            ("sharrattj/coreutils/1.0.11.webc", COREUTILS_11),
346            ("sharrattj/coreutils/1.0.16.webc", COREUTILS_16),
347        ]);
348        let source = LocalRegistrySource::new(temp.path()).unwrap();
349
350        let all = query(&source, &"sharrattj/coreutils".parse().unwrap()).unwrap();
351        assert_eq!(
352            all.iter().map(|s| s.pkg.id.clone()).collect::<Vec<_>>(),
353            [
354                named("sharrattj/coreutils", "1.0.11"),
355                named("sharrattj/coreutils", "1.0.16"),
356            ]
357        );
358
359        let pinned = query(&source, &"sharrattj/coreutils@^1.0.16".parse().unwrap()).unwrap();
360        assert_eq!(pinned.len(), 1);
361        assert_eq!(pinned[0].pkg.id, named("sharrattj/coreutils", "1.0.16"));
362
363        assert!(matches!(
364            query(&source, &"sharrattj/unknown".parse().unwrap()),
365            Err(QueryError::NotFound { .. })
366        ));
367        assert!(matches!(
368            query(&source, &"sharrattj/coreutils@^2".parse().unwrap()),
369            Err(QueryError::NoMatches { .. })
370        ));
371    }
372
373    #[test]
374    fn unnamespaced_packages_sit_one_level_up() {
375        let temp = registry(&[("cutils/1.0.0.webc", COREUTILS_16)]);
376        let source = LocalRegistrySource::new(temp.path()).unwrap();
377
378        let summaries = query(&source, &"cutils".parse().unwrap()).unwrap();
379
380        assert_eq!(summaries[0].pkg.id, named("cutils", "1.0.0"));
381    }
382
383    #[test]
384    fn sha256_sidecar_is_verified() {
385        let temp = registry(&[("acme/cutils/9.9.9.webc", COREUTILS_16)]);
386        let sidecar = temp.path().join("acme/cutils/9.9.9.sha256");
387        let source = LocalRegistrySource::new(temp.path()).unwrap();
388        let pkg: PackageSource = "acme/cutils".parse().unwrap();
389
390        std::fs::write(&sidecar, WebcHash::sha256(COREUTILS_16).as_hex()).unwrap();
391        assert!(query(&source, &pkg).is_ok());
392
393        std::fs::write(&sidecar, "deadbeef").unwrap();
394        assert!(matches!(
395            query(&source, &pkg),
396            Err(QueryError::Other { .. })
397        ));
398    }
399
400    #[test]
401    fn hash_queries_walk_the_tree() {
402        let temp = registry(&[
403            ("acme/cutils/9.9.9.webc", COREUTILS_16),
404            ("other/pkg/1.0.0.webc", COREUTILS_11),
405        ]);
406        let source = LocalRegistrySource::new(temp.path()).unwrap();
407        let hash = |bytes| PackageHash::from_sha256_bytes(WebcHash::sha256(bytes).as_bytes());
408        let by_hash = |hash| PackageSource::Ident(PackageIdent::Hash(hash));
409
410        let summaries = query(&source, &by_hash(hash(COREUTILS_16))).unwrap();
411        assert_eq!(summaries[0].pkg.id, named("acme/cutils", "9.9.9"));
412
413        assert!(matches!(
414            query(&source, &by_hash(PackageHash::from_sha256_bytes([0; 32]))),
415            Err(QueryError::NotFound { .. })
416        ));
417    }
418
419    #[test]
420    fn parse_path_ids() {
421        let root = Path::new("/pkgs");
422        let id = |p: &str| id_from_path(root, &root.join(p)).map(|id| id.to_string());
423
424        assert_eq!(id("ns/name/1.2.3.webc").as_deref(), Some("ns/name@1.2.3"));
425        assert_eq!(id("name/1.2.3.webc").as_deref(), Some("name@1.2.3"));
426        assert_eq!(
427            id("ns/name/1.0.0-rc.1.webc").as_deref(),
428            Some("ns/name@1.0.0-rc.1")
429        );
430        // Doesn't fit the layout -> None, so a walk keeps the manifest id.
431        assert_eq!(id("ns/name/notaversion.webc"), None); // bad semver
432        assert_eq!(id("too/deep/ns/name/1.0.0.webc"), None); // wrong depth
433        assert_eq!(id("flat.webc"), None); // no version directory
434    }
435
436    #[test]
437    fn package_dirs_stay_under_the_root() {
438        let root = Path::new("/pkgs");
439
440        assert_eq!(
441            package_dir_path(root, "ns/name"),
442            Some(PathBuf::from("/pkgs/ns/name"))
443        );
444        assert_eq!(
445            package_dir_path(root, "name"),
446            Some(PathBuf::from("/pkgs/name"))
447        );
448        assert_eq!(package_dir_path(root, "ns/.."), None);
449        assert_eq!(package_dir_path(root, ""), None);
450    }
451}