1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use serde::Deserialize;
20use sha2::{Digest, Sha256};
21
22use crate::error::PkgError;
23use crate::resolver::{PackageRegistry, PackageVersionMeta};
24
25#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
27pub struct VersionsResponse {
28 pub versions: Vec<String>,
30 pub latest: String,
32}
33
34#[derive(Debug, Clone, Deserialize)]
36pub struct VersionMetaResponse {
37 pub manifest: ManifestData,
39 pub checksum: String,
41 #[serde(default)]
44 pub download_url: String,
45}
46
47#[derive(Debug, Clone, Deserialize, Default)]
49pub struct ManifestData {
50 #[serde(default)]
52 pub dependencies: BTreeMap<String, String>,
53 #[serde(default)]
55 pub supported_targets: Option<Vec<String>>,
56 #[serde(default)]
58 pub available_features: BTreeMap<String, Vec<String>>,
59 #[serde(default)]
61 pub dep_features: BTreeMap<String, Vec<String>>,
62}
63
64pub const AUTH_TOKEN_ENV: &str = "BOCK_REGISTRY_TOKEN";
66
67pub struct NetworkRegistry {
72 base_url: String,
73 client: reqwest::blocking::Client,
74 cache_dir: PathBuf,
75 fallback: Option<PackageRegistry>,
76 auth_token: Option<String>,
77}
78
79impl NetworkRegistry {
80 pub fn new(
84 base_url: impl Into<String>,
85 cache_dir: impl Into<PathBuf>,
86 ) -> Result<Self, PkgError> {
87 let client = reqwest::blocking::Client::builder()
88 .timeout(Duration::from_secs(30))
89 .user_agent(concat!("bock-pkg/", env!("CARGO_PKG_VERSION")))
90 .build()
91 .map_err(|e| PkgError::Network(e.to_string()))?;
92 let cache_dir = cache_dir.into();
93 std::fs::create_dir_all(&cache_dir).map_err(|e| PkgError::Io(e.to_string()))?;
94 let base_url = base_url.into().trim_end_matches('/').to_string();
95 Ok(Self {
96 base_url,
97 client,
98 cache_dir,
99 fallback: None,
100 auth_token: std::env::var(AUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty()),
101 })
102 }
103
104 #[must_use]
107 pub fn with_fallback(mut self, fallback: PackageRegistry) -> Self {
108 self.fallback = Some(fallback);
109 self
110 }
111
112 #[must_use]
118 pub fn with_auth_token(mut self, token: Option<String>) -> Self {
119 self.auth_token = token.filter(|s| !s.is_empty());
120 self
121 }
122
123 #[must_use]
125 pub fn auth_token(&self) -> Option<&str> {
126 self.auth_token.as_deref()
127 }
128
129 fn authed_get(&self, url: &str) -> reqwest::blocking::RequestBuilder {
130 let mut req = self.client.get(url);
131 if let Some(token) = &self.auth_token {
132 req = req.bearer_auth(token);
133 }
134 req
135 }
136
137 #[must_use]
139 pub fn base_url(&self) -> &str {
140 &self.base_url
141 }
142
143 #[must_use]
145 pub fn cache_dir(&self) -> &Path {
146 &self.cache_dir
147 }
148
149 pub fn fetch_versions(&self, name: &str) -> Result<VersionsResponse, PkgError> {
151 let url = format!("{}/packages/{}", self.base_url, name);
152 let response = self
153 .authed_get(&url)
154 .send()
155 .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
156 if response.status() == reqwest::StatusCode::NOT_FOUND {
157 return Err(PkgError::PackageNotFound(name.to_string()));
158 }
159 if !response.status().is_success() {
160 return Err(PkgError::Network(format!(
161 "GET {url}: status {}",
162 response.status()
163 )));
164 }
165 response
166 .json()
167 .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
168 }
169
170 pub fn fetch_version_meta(
172 &self,
173 name: &str,
174 version: &str,
175 ) -> Result<VersionMetaResponse, PkgError> {
176 let url = format!("{}/packages/{}/{}", self.base_url, name, version);
177 let response = self
178 .authed_get(&url)
179 .send()
180 .map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
181 if response.status() == reqwest::StatusCode::NOT_FOUND {
182 return Err(PkgError::PackageNotFound(format!("{name}@{version}")));
183 }
184 if !response.status().is_success() {
185 return Err(PkgError::Network(format!(
186 "GET {url}: status {}",
187 response.status()
188 )));
189 }
190 response
191 .json()
192 .map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
193 }
194
195 pub fn download_package(&self, name: &str, version: &str) -> Result<PathBuf, PkgError> {
201 let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
202 if cache_path.exists() {
203 return Ok(cache_path);
204 }
205
206 let meta = self.fetch_version_meta(name, version)?;
207 let tarball_url = if meta.download_url.is_empty() {
208 format!("{}/packages/{}/{}/download", self.base_url, name, version)
209 } else {
210 meta.download_url.clone()
211 };
212
213 let response = self
214 .authed_get(&tarball_url)
215 .send()
216 .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
217 if !response.status().is_success() {
218 return Err(PkgError::Network(format!(
219 "GET {tarball_url}: status {}",
220 response.status()
221 )));
222 }
223 let bytes = response
224 .bytes()
225 .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
226
227 verify_checksum(&bytes, &meta.checksum)?;
228
229 std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
230 Ok(cache_path)
231 }
232
233 pub fn fetch_package(&self, name: &str, version: &str) -> Result<FetchedPackage, PkgError> {
239 let meta = self.fetch_version_meta(name, version)?;
240 let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
241
242 if !cache_path.exists() {
243 let tarball_url = if meta.download_url.is_empty() {
244 format!("{}/packages/{}/{}/download", self.base_url, name, version)
245 } else {
246 meta.download_url.clone()
247 };
248
249 let response = self
250 .authed_get(&tarball_url)
251 .send()
252 .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
253 if !response.status().is_success() {
254 return Err(PkgError::Network(format!(
255 "GET {tarball_url}: status {}",
256 response.status()
257 )));
258 }
259 let bytes = response
260 .bytes()
261 .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
262
263 verify_checksum(&bytes, &meta.checksum)?;
264
265 std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
266 } else {
267 let bytes = std::fs::read(&cache_path).map_err(|e| PkgError::Io(e.to_string()))?;
269 verify_checksum(&bytes, &meta.checksum)?;
270 }
271
272 Ok(FetchedPackage {
273 tarball_path: cache_path,
274 checksum: normalize_checksum(&meta.checksum),
275 meta,
276 })
277 }
278
279 pub fn hydrate(&self, names: &[&str]) -> Result<PackageRegistry, PkgError> {
287 let mut registry = self.fallback.clone().unwrap_or_default();
288 for name in names {
289 match self.fetch_versions(name) {
290 Ok(versions) => {
291 for version in &versions.versions {
292 match self.fetch_version_meta(name, version) {
293 Ok(meta) => {
294 let pkg_meta = PackageVersionMeta {
295 deps: meta.manifest.dependencies,
296 dep_features: meta.manifest.dep_features,
297 supported_targets: meta.manifest.supported_targets,
298 available_features: meta.manifest.available_features,
299 };
300 registry.register_with_meta(name, version, pkg_meta)?;
301 }
302 Err(PkgError::Network(_)) if self.fallback.is_some() => {}
303 Err(e) => return Err(e),
304 }
305 }
306 }
307 Err(PkgError::Network(_)) if self.fallback.is_some() => {}
308 Err(PkgError::PackageNotFound(_)) if registry.has_package(name) => {
309 }
311 Err(e) => return Err(e),
312 }
313 }
314 Ok(registry)
315 }
316}
317
318#[derive(Debug, Clone)]
320pub struct FetchedPackage {
321 pub tarball_path: PathBuf,
323 pub checksum: String,
325 pub meta: VersionMetaResponse,
327}
328
329#[must_use]
331pub fn normalize_checksum(checksum: &str) -> String {
332 checksum
333 .strip_prefix("sha256:")
334 .unwrap_or(checksum)
335 .to_ascii_lowercase()
336}
337
338pub fn verify_checksum(data: &[u8], expected: &str) -> Result<(), PkgError> {
343 let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
344 let actual = sha256_hex(data);
345 if !actual.eq_ignore_ascii_case(expected_hex) {
346 return Err(PkgError::ChecksumMismatch {
347 expected: expected_hex.to_string(),
348 actual,
349 });
350 }
351 Ok(())
352}
353
354#[must_use]
356pub fn sha256_hex(data: &[u8]) -> String {
357 let digest = Sha256::digest(data);
358 let mut out = String::with_capacity(digest.len() * 2);
359 for byte in digest {
360 use std::fmt::Write;
361 let _ = write!(out, "{byte:02x}");
362 }
363 out
364}
365
366#[derive(Debug, Clone, Deserialize, Default)]
368pub struct RegistriesSection {
369 pub default: Option<String>,
372 #[serde(flatten)]
374 pub named: BTreeMap<String, String>,
375}
376
377pub fn parse_registries(project_toml: &str) -> Result<RegistriesSection, PkgError> {
381 #[derive(Deserialize)]
382 struct Wrapper {
383 #[serde(default)]
384 registries: RegistriesSection,
385 }
386 let wrapper: Wrapper = toml::from_str(project_toml)
387 .map_err(|e| PkgError::ManifestParse(format!("bock.project: {e}")))?;
388 Ok(wrapper.registries)
389}
390
391pub fn default_registry_url(project_dir: &Path) -> Option<String> {
397 let path = project_dir.join("bock.project");
398 let content = std::fs::read_to_string(&path).ok()?;
399 parse_registries(&content).ok()?.default
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use mockito::Server;
406
407 fn tmp_cache() -> (tempfile::TempDir, PathBuf) {
408 let dir = tempfile::tempdir().unwrap();
409 let path = dir.path().join("cache");
410 (dir, path)
411 }
412
413 #[test]
414 fn sha256_hex_matches_known_vector() {
415 assert_eq!(
417 sha256_hex(b"abc"),
418 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
419 );
420 }
421
422 #[test]
423 fn verify_checksum_accepts_matching_hex() {
424 let bytes = b"hello world";
425 let hex = sha256_hex(bytes);
426 verify_checksum(bytes, &hex).unwrap();
427 }
428
429 #[test]
430 fn verify_checksum_accepts_sha256_prefix() {
431 let bytes = b"hello world";
432 let hex = sha256_hex(bytes);
433 verify_checksum(bytes, &format!("sha256:{hex}")).unwrap();
434 }
435
436 #[test]
437 fn verify_checksum_rejects_mismatch() {
438 let result = verify_checksum(b"hello", "sha256:deadbeef");
439 assert!(matches!(result, Err(PkgError::ChecksumMismatch { .. })));
440 }
441
442 #[test]
443 fn fetch_versions_parses_response() {
444 let mut server = Server::new();
445 let mock = server
446 .mock("GET", "/packages/foo")
447 .with_status(200)
448 .with_header("content-type", "application/json")
449 .with_body(r#"{"versions":["1.0.0","1.1.0"],"latest":"1.1.0"}"#)
450 .create();
451
452 let (_tmp, cache) = tmp_cache();
453 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
454 let resp = reg.fetch_versions("foo").unwrap();
455
456 assert_eq!(resp.versions, vec!["1.0.0", "1.1.0"]);
457 assert_eq!(resp.latest, "1.1.0");
458 mock.assert();
459 }
460
461 #[test]
462 fn fetch_versions_maps_404_to_package_not_found() {
463 let mut server = Server::new();
464 let _mock = server
465 .mock("GET", "/packages/missing")
466 .with_status(404)
467 .create();
468
469 let (_tmp, cache) = tmp_cache();
470 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
471 let err = reg.fetch_versions("missing").unwrap_err();
472 assert!(matches!(err, PkgError::PackageNotFound(_)));
473 }
474
475 #[test]
476 fn fetch_version_meta_parses_manifest() {
477 let mut server = Server::new();
478 let body = r#"{
479 "manifest": {
480 "dependencies": {"bar": "^1.0"},
481 "supported_targets": ["js", "rust"],
482 "available_features": {"json": []}
483 },
484 "checksum": "sha256:abc",
485 "download_url": ""
486 }"#;
487 let mock = server
488 .mock("GET", "/packages/foo/1.0.0")
489 .with_status(200)
490 .with_header("content-type", "application/json")
491 .with_body(body)
492 .create();
493
494 let (_tmp, cache) = tmp_cache();
495 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
496 let meta = reg.fetch_version_meta("foo", "1.0.0").unwrap();
497
498 assert_eq!(meta.checksum, "sha256:abc");
499 assert_eq!(meta.manifest.dependencies["bar"], "^1.0");
500 assert_eq!(
501 meta.manifest.supported_targets,
502 Some(vec!["js".into(), "rust".into()])
503 );
504 mock.assert();
505 }
506
507 #[test]
508 fn download_package_verifies_and_caches() {
509 let mut server = Server::new();
510 let tarball = b"fake tarball contents";
511 let checksum = sha256_hex(tarball);
512 let body = format!(
513 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
514 );
515 let _meta = server
516 .mock("GET", "/packages/foo/1.0.0")
517 .with_status(200)
518 .with_header("content-type", "application/json")
519 .with_body(body)
520 .create();
521 let _download = server
522 .mock("GET", "/packages/foo/1.0.0/download")
523 .with_status(200)
524 .with_body(tarball)
525 .create();
526
527 let (_tmp, cache) = tmp_cache();
528 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
529 let path = reg.download_package("foo", "1.0.0").unwrap();
530
531 assert!(path.exists());
532 assert_eq!(std::fs::read(&path).unwrap(), tarball);
533
534 let again = reg.download_package("foo", "1.0.0").unwrap();
536 assert_eq!(again, path);
537 }
538
539 #[test]
540 fn download_package_rejects_bad_checksum() {
541 let mut server = Server::new();
542 let tarball = b"bytes that do not match";
543 let body =
544 r#"{"manifest":{"dependencies":{}},"checksum":"sha256:deadbeef","download_url":""}"#;
545 let _meta = server
546 .mock("GET", "/packages/foo/1.0.0")
547 .with_status(200)
548 .with_header("content-type", "application/json")
549 .with_body(body)
550 .create();
551 let _download = server
552 .mock("GET", "/packages/foo/1.0.0/download")
553 .with_status(200)
554 .with_body(tarball)
555 .create();
556
557 let (_tmp, cache) = tmp_cache();
558 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
559 let err = reg.download_package("foo", "1.0.0").unwrap_err();
560 assert!(matches!(err, PkgError::ChecksumMismatch { .. }));
561 assert!(!cache.join("foo-1.0.0.tar.gz").exists());
563 }
564
565 #[test]
566 fn download_package_honors_custom_download_url() {
567 let mut server = Server::new();
568 let tarball = b"custom url payload";
569 let checksum = sha256_hex(tarball);
570 let custom_url = format!("{}/mirror/foo-1.0.0.tgz", server.url());
571 let body = format!(
572 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":"{custom_url}"}}"#
573 );
574 let _meta = server
575 .mock("GET", "/packages/foo/1.0.0")
576 .with_status(200)
577 .with_header("content-type", "application/json")
578 .with_body(body)
579 .create();
580 let _download = server
581 .mock("GET", "/mirror/foo-1.0.0.tgz")
582 .with_status(200)
583 .with_body(tarball)
584 .create();
585
586 let (_tmp, cache) = tmp_cache();
587 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
588 let path = reg.download_package("foo", "1.0.0").unwrap();
589 assert_eq!(std::fs::read(&path).unwrap(), tarball);
590 }
591
592 #[test]
593 fn hydrate_populates_registry_from_network() {
594 let mut server = Server::new();
595 let _v = server
596 .mock("GET", "/packages/foo")
597 .with_status(200)
598 .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
599 .create();
600 let _m = server
601 .mock("GET", "/packages/foo/1.0.0")
602 .with_status(200)
603 .with_body(
604 r#"{"manifest":{"dependencies":{"bar":"^1.0"}},"checksum":"sha256:x","download_url":""}"#,
605 )
606 .create();
607
608 let (_tmp, cache) = tmp_cache();
609 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
610 let registry = reg.hydrate(&["foo"]).unwrap();
611
612 assert!(registry.has_package("foo"));
613 assert_eq!(registry.available_versions("foo").len(), 1);
614 }
615
616 #[test]
617 fn hydrate_falls_back_when_network_unreachable() {
618 let mut fallback = PackageRegistry::new();
621 fallback.register("foo", "1.0.0", BTreeMap::new()).unwrap();
622
623 let (_tmp, cache) = tmp_cache();
624 let reg = NetworkRegistry::new("http://127.0.0.1:1/", cache)
625 .unwrap()
626 .with_fallback(fallback);
627
628 let registry = reg.hydrate(&["foo"]).unwrap();
629 assert!(registry.has_package("foo"));
630 }
631
632 #[test]
633 fn parse_registries_reads_default_and_named() {
634 let project = r#"
635[project]
636name = "test"
637version = "0.1.0"
638
639[registries]
640default = "https://registry.bock-lang.dev/api/v1"
641internal = "https://bock.company.internal"
642"#;
643 let regs = parse_registries(project).unwrap();
644 assert_eq!(
645 regs.default.as_deref(),
646 Some("https://registry.bock-lang.dev/api/v1")
647 );
648 assert_eq!(
649 regs.named.get("internal").map(String::as_str),
650 Some("https://bock.company.internal"),
651 );
652 }
653
654 #[test]
655 fn parse_registries_missing_section_is_empty() {
656 let project = r#"
657[project]
658name = "test"
659version = "0.1.0"
660"#;
661 let regs = parse_registries(project).unwrap();
662 assert!(regs.default.is_none());
663 assert!(regs.named.is_empty());
664 }
665
666 #[test]
667 fn default_registry_url_reads_from_file() {
668 let dir = tempfile::tempdir().unwrap();
669 std::fs::write(
670 dir.path().join("bock.project"),
671 "[project]\nname = \"t\"\nversion = \"0.1.0\"\n\n[registries]\ndefault = \"https://example.com/api/v1\"\n",
672 )
673 .unwrap();
674 assert_eq!(
675 default_registry_url(dir.path()).as_deref(),
676 Some("https://example.com/api/v1")
677 );
678 }
679
680 #[test]
681 fn default_registry_url_missing_file_is_none() {
682 let dir = tempfile::tempdir().unwrap();
683 assert!(default_registry_url(dir.path()).is_none());
684 }
685}