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(
239 &self,
240 name: &str,
241 version: &str,
242 ) -> Result<FetchedPackage, PkgError> {
243 let meta = self.fetch_version_meta(name, version)?;
244 let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
245
246 if !cache_path.exists() {
247 let tarball_url = if meta.download_url.is_empty() {
248 format!("{}/packages/{}/{}/download", self.base_url, name, version)
249 } else {
250 meta.download_url.clone()
251 };
252
253 let response = self
254 .authed_get(&tarball_url)
255 .send()
256 .map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
257 if !response.status().is_success() {
258 return Err(PkgError::Network(format!(
259 "GET {tarball_url}: status {}",
260 response.status()
261 )));
262 }
263 let bytes = response
264 .bytes()
265 .map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
266
267 verify_checksum(&bytes, &meta.checksum)?;
268
269 std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
270 } else {
271 let bytes = std::fs::read(&cache_path).map_err(|e| PkgError::Io(e.to_string()))?;
273 verify_checksum(&bytes, &meta.checksum)?;
274 }
275
276 Ok(FetchedPackage {
277 tarball_path: cache_path,
278 checksum: normalize_checksum(&meta.checksum),
279 meta,
280 })
281 }
282
283 pub fn hydrate(&self, names: &[&str]) -> Result<PackageRegistry, PkgError> {
291 let mut registry = self.fallback.clone().unwrap_or_default();
292 for name in names {
293 match self.fetch_versions(name) {
294 Ok(versions) => {
295 for version in &versions.versions {
296 match self.fetch_version_meta(name, version) {
297 Ok(meta) => {
298 let pkg_meta = PackageVersionMeta {
299 deps: meta.manifest.dependencies,
300 dep_features: meta.manifest.dep_features,
301 supported_targets: meta.manifest.supported_targets,
302 available_features: meta.manifest.available_features,
303 };
304 registry.register_with_meta(name, version, pkg_meta)?;
305 }
306 Err(PkgError::Network(_)) if self.fallback.is_some() => {}
307 Err(e) => return Err(e),
308 }
309 }
310 }
311 Err(PkgError::Network(_)) if self.fallback.is_some() => {}
312 Err(PkgError::PackageNotFound(_)) if registry.has_package(name) => {
313 }
315 Err(e) => return Err(e),
316 }
317 }
318 Ok(registry)
319 }
320}
321
322#[derive(Debug, Clone)]
324pub struct FetchedPackage {
325 pub tarball_path: PathBuf,
327 pub checksum: String,
329 pub meta: VersionMetaResponse,
331}
332
333#[must_use]
335pub fn normalize_checksum(checksum: &str) -> String {
336 checksum
337 .strip_prefix("sha256:")
338 .unwrap_or(checksum)
339 .to_ascii_lowercase()
340}
341
342pub fn verify_checksum(data: &[u8], expected: &str) -> Result<(), PkgError> {
347 let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
348 let actual = sha256_hex(data);
349 if !actual.eq_ignore_ascii_case(expected_hex) {
350 return Err(PkgError::ChecksumMismatch {
351 expected: expected_hex.to_string(),
352 actual,
353 });
354 }
355 Ok(())
356}
357
358#[must_use]
360pub fn sha256_hex(data: &[u8]) -> String {
361 let digest = Sha256::digest(data);
362 let mut out = String::with_capacity(digest.len() * 2);
363 for byte in digest {
364 use std::fmt::Write;
365 let _ = write!(out, "{byte:02x}");
366 }
367 out
368}
369
370#[derive(Debug, Clone, Deserialize, Default)]
372pub struct RegistriesSection {
373 pub default: Option<String>,
376 #[serde(flatten)]
378 pub named: BTreeMap<String, String>,
379}
380
381pub fn parse_registries(project_toml: &str) -> Result<RegistriesSection, PkgError> {
385 #[derive(Deserialize)]
386 struct Wrapper {
387 #[serde(default)]
388 registries: RegistriesSection,
389 }
390 let wrapper: Wrapper = toml::from_str(project_toml)
391 .map_err(|e| PkgError::ManifestParse(format!("bock.project: {e}")))?;
392 Ok(wrapper.registries)
393}
394
395pub fn default_registry_url(project_dir: &Path) -> Option<String> {
401 let path = project_dir.join("bock.project");
402 let content = std::fs::read_to_string(&path).ok()?;
403 parse_registries(&content).ok()?.default
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use mockito::Server;
410
411 fn tmp_cache() -> (tempfile::TempDir, PathBuf) {
412 let dir = tempfile::tempdir().unwrap();
413 let path = dir.path().join("cache");
414 (dir, path)
415 }
416
417 #[test]
418 fn sha256_hex_matches_known_vector() {
419 assert_eq!(
421 sha256_hex(b"abc"),
422 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
423 );
424 }
425
426 #[test]
427 fn verify_checksum_accepts_matching_hex() {
428 let bytes = b"hello world";
429 let hex = sha256_hex(bytes);
430 verify_checksum(bytes, &hex).unwrap();
431 }
432
433 #[test]
434 fn verify_checksum_accepts_sha256_prefix() {
435 let bytes = b"hello world";
436 let hex = sha256_hex(bytes);
437 verify_checksum(bytes, &format!("sha256:{hex}")).unwrap();
438 }
439
440 #[test]
441 fn verify_checksum_rejects_mismatch() {
442 let result = verify_checksum(b"hello", "sha256:deadbeef");
443 assert!(matches!(result, Err(PkgError::ChecksumMismatch { .. })));
444 }
445
446 #[test]
447 fn fetch_versions_parses_response() {
448 let mut server = Server::new();
449 let mock = server
450 .mock("GET", "/packages/foo")
451 .with_status(200)
452 .with_header("content-type", "application/json")
453 .with_body(r#"{"versions":["1.0.0","1.1.0"],"latest":"1.1.0"}"#)
454 .create();
455
456 let (_tmp, cache) = tmp_cache();
457 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
458 let resp = reg.fetch_versions("foo").unwrap();
459
460 assert_eq!(resp.versions, vec!["1.0.0", "1.1.0"]);
461 assert_eq!(resp.latest, "1.1.0");
462 mock.assert();
463 }
464
465 #[test]
466 fn fetch_versions_maps_404_to_package_not_found() {
467 let mut server = Server::new();
468 let _mock = server
469 .mock("GET", "/packages/missing")
470 .with_status(404)
471 .create();
472
473 let (_tmp, cache) = tmp_cache();
474 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
475 let err = reg.fetch_versions("missing").unwrap_err();
476 assert!(matches!(err, PkgError::PackageNotFound(_)));
477 }
478
479 #[test]
480 fn fetch_version_meta_parses_manifest() {
481 let mut server = Server::new();
482 let body = r#"{
483 "manifest": {
484 "dependencies": {"bar": "^1.0"},
485 "supported_targets": ["js", "rust"],
486 "available_features": {"json": []}
487 },
488 "checksum": "sha256:abc",
489 "download_url": ""
490 }"#;
491 let mock = server
492 .mock("GET", "/packages/foo/1.0.0")
493 .with_status(200)
494 .with_header("content-type", "application/json")
495 .with_body(body)
496 .create();
497
498 let (_tmp, cache) = tmp_cache();
499 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
500 let meta = reg.fetch_version_meta("foo", "1.0.0").unwrap();
501
502 assert_eq!(meta.checksum, "sha256:abc");
503 assert_eq!(meta.manifest.dependencies["bar"], "^1.0");
504 assert_eq!(
505 meta.manifest.supported_targets,
506 Some(vec!["js".into(), "rust".into()])
507 );
508 mock.assert();
509 }
510
511 #[test]
512 fn download_package_verifies_and_caches() {
513 let mut server = Server::new();
514 let tarball = b"fake tarball contents";
515 let checksum = sha256_hex(tarball);
516 let body = format!(
517 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
518 );
519 let _meta = server
520 .mock("GET", "/packages/foo/1.0.0")
521 .with_status(200)
522 .with_header("content-type", "application/json")
523 .with_body(body)
524 .create();
525 let _download = server
526 .mock("GET", "/packages/foo/1.0.0/download")
527 .with_status(200)
528 .with_body(tarball)
529 .create();
530
531 let (_tmp, cache) = tmp_cache();
532 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
533 let path = reg.download_package("foo", "1.0.0").unwrap();
534
535 assert!(path.exists());
536 assert_eq!(std::fs::read(&path).unwrap(), tarball);
537
538 let again = reg.download_package("foo", "1.0.0").unwrap();
540 assert_eq!(again, path);
541 }
542
543 #[test]
544 fn download_package_rejects_bad_checksum() {
545 let mut server = Server::new();
546 let tarball = b"bytes that do not match";
547 let body = r#"{"manifest":{"dependencies":{}},"checksum":"sha256:deadbeef","download_url":""}"#;
548 let _meta = server
549 .mock("GET", "/packages/foo/1.0.0")
550 .with_status(200)
551 .with_header("content-type", "application/json")
552 .with_body(body)
553 .create();
554 let _download = server
555 .mock("GET", "/packages/foo/1.0.0/download")
556 .with_status(200)
557 .with_body(tarball)
558 .create();
559
560 let (_tmp, cache) = tmp_cache();
561 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
562 let err = reg.download_package("foo", "1.0.0").unwrap_err();
563 assert!(matches!(err, PkgError::ChecksumMismatch { .. }));
564 assert!(!cache.join("foo-1.0.0.tar.gz").exists());
566 }
567
568 #[test]
569 fn download_package_honors_custom_download_url() {
570 let mut server = Server::new();
571 let tarball = b"custom url payload";
572 let checksum = sha256_hex(tarball);
573 let custom_url = format!("{}/mirror/foo-1.0.0.tgz", server.url());
574 let body = format!(
575 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":"{custom_url}"}}"#
576 );
577 let _meta = server
578 .mock("GET", "/packages/foo/1.0.0")
579 .with_status(200)
580 .with_header("content-type", "application/json")
581 .with_body(body)
582 .create();
583 let _download = server
584 .mock("GET", "/mirror/foo-1.0.0.tgz")
585 .with_status(200)
586 .with_body(tarball)
587 .create();
588
589 let (_tmp, cache) = tmp_cache();
590 let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
591 let path = reg.download_package("foo", "1.0.0").unwrap();
592 assert_eq!(std::fs::read(&path).unwrap(), tarball);
593 }
594
595 #[test]
596 fn hydrate_populates_registry_from_network() {
597 let mut server = Server::new();
598 let _v = server
599 .mock("GET", "/packages/foo")
600 .with_status(200)
601 .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
602 .create();
603 let _m = server
604 .mock("GET", "/packages/foo/1.0.0")
605 .with_status(200)
606 .with_body(
607 r#"{"manifest":{"dependencies":{"bar":"^1.0"}},"checksum":"sha256:x","download_url":""}"#,
608 )
609 .create();
610
611 let (_tmp, cache) = tmp_cache();
612 let reg = NetworkRegistry::new(server.url(), cache).unwrap();
613 let registry = reg.hydrate(&["foo"]).unwrap();
614
615 assert!(registry.has_package("foo"));
616 assert_eq!(registry.available_versions("foo").len(), 1);
617 }
618
619 #[test]
620 fn hydrate_falls_back_when_network_unreachable() {
621 let mut fallback = PackageRegistry::new();
624 fallback
625 .register("foo", "1.0.0", BTreeMap::new())
626 .unwrap();
627
628 let (_tmp, cache) = tmp_cache();
629 let reg = NetworkRegistry::new("http://127.0.0.1:1/", cache)
630 .unwrap()
631 .with_fallback(fallback);
632
633 let registry = reg.hydrate(&["foo"]).unwrap();
634 assert!(registry.has_package("foo"));
635 }
636
637 #[test]
638 fn parse_registries_reads_default_and_named() {
639 let project = r#"
640[project]
641name = "test"
642version = "0.1.0"
643
644[registries]
645default = "https://registry.bock-lang.dev/api/v1"
646internal = "https://bock.company.internal"
647"#;
648 let regs = parse_registries(project).unwrap();
649 assert_eq!(
650 regs.default.as_deref(),
651 Some("https://registry.bock-lang.dev/api/v1")
652 );
653 assert_eq!(
654 regs.named.get("internal").map(String::as_str),
655 Some("https://bock.company.internal"),
656 );
657 }
658
659 #[test]
660 fn parse_registries_missing_section_is_empty() {
661 let project = r#"
662[project]
663name = "test"
664version = "0.1.0"
665"#;
666 let regs = parse_registries(project).unwrap();
667 assert!(regs.default.is_none());
668 assert!(regs.named.is_empty());
669 }
670
671 #[test]
672 fn default_registry_url_reads_from_file() {
673 let dir = tempfile::tempdir().unwrap();
674 std::fs::write(
675 dir.path().join("bock.project"),
676 "[project]\nname = \"t\"\nversion = \"0.1.0\"\n\n[registries]\ndefault = \"https://example.com/api/v1\"\n",
677 )
678 .unwrap();
679 assert_eq!(
680 default_registry_url(dir.path()).as_deref(),
681 Some("https://example.com/api/v1")
682 );
683 }
684
685 #[test]
686 fn default_registry_url_missing_file_is_none() {
687 let dir = tempfile::tempdir().unwrap();
688 assert!(default_registry_url(dir.path()).is_none());
689 }
690}