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#[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 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 let Some(dir) = package_dir_path(&self.root, &full_name) else {
46 return Err(QueryError::NotFound {
47 query: query.clone(),
48 });
49 };
50 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 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 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 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 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
171fn 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
188fn 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
210fn 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
226fn 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
241fn 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
247fn 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 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 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 assert!(!summaries[0].pkg.commands.is_empty());
321 }
322
323 #[test]
324 fn only_matching_versions_are_opened() {
325 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 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 assert_eq!(id("ns/name/notaversion.webc"), None); assert_eq!(id("too/deep/ns/name/1.0.0.webc"), None); assert_eq!(id("flat.webc"), None); }
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}