use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::error::PkgError;
use crate::resolver::{PackageRegistry, PackageVersionMeta};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct VersionsResponse {
pub versions: Vec<String>,
pub latest: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VersionMetaResponse {
pub manifest: ManifestData,
pub checksum: String,
#[serde(default)]
pub download_url: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ManifestData {
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
#[serde(default)]
pub supported_targets: Option<Vec<String>>,
#[serde(default)]
pub available_features: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub dep_features: BTreeMap<String, Vec<String>>,
}
pub const AUTH_TOKEN_ENV: &str = "BOCK_REGISTRY_TOKEN";
pub struct NetworkRegistry {
base_url: String,
client: reqwest::blocking::Client,
cache_dir: PathBuf,
fallback: Option<PackageRegistry>,
auth_token: Option<String>,
}
impl NetworkRegistry {
pub fn new(
base_url: impl Into<String>,
cache_dir: impl Into<PathBuf>,
) -> Result<Self, PkgError> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(concat!("bock-pkg/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| PkgError::Network(e.to_string()))?;
let cache_dir = cache_dir.into();
std::fs::create_dir_all(&cache_dir).map_err(|e| PkgError::Io(e.to_string()))?;
let base_url = base_url.into().trim_end_matches('/').to_string();
Ok(Self {
base_url,
client,
cache_dir,
fallback: None,
auth_token: std::env::var(AUTH_TOKEN_ENV).ok().filter(|s| !s.is_empty()),
})
}
#[must_use]
pub fn with_fallback(mut self, fallback: PackageRegistry) -> Self {
self.fallback = Some(fallback);
self
}
#[must_use]
pub fn with_auth_token(mut self, token: Option<String>) -> Self {
self.auth_token = token.filter(|s| !s.is_empty());
self
}
#[must_use]
pub fn auth_token(&self) -> Option<&str> {
self.auth_token.as_deref()
}
fn authed_get(&self, url: &str) -> reqwest::blocking::RequestBuilder {
let mut req = self.client.get(url);
if let Some(token) = &self.auth_token {
req = req.bearer_auth(token);
}
req
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn fetch_versions(&self, name: &str) -> Result<VersionsResponse, PkgError> {
let url = format!("{}/packages/{}", self.base_url, name);
let response = self
.authed_get(&url)
.send()
.map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PkgError::PackageNotFound(name.to_string()));
}
if !response.status().is_success() {
return Err(PkgError::Network(format!(
"GET {url}: status {}",
response.status()
)));
}
response
.json()
.map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
}
pub fn fetch_version_meta(
&self,
name: &str,
version: &str,
) -> Result<VersionMetaResponse, PkgError> {
let url = format!("{}/packages/{}/{}", self.base_url, name, version);
let response = self
.authed_get(&url)
.send()
.map_err(|e| PkgError::Network(format!("GET {url}: {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PkgError::PackageNotFound(format!("{name}@{version}")));
}
if !response.status().is_success() {
return Err(PkgError::Network(format!(
"GET {url}: status {}",
response.status()
)));
}
response
.json()
.map_err(|e| PkgError::Network(format!("decoding {url}: {e}")))
}
pub fn download_package(&self, name: &str, version: &str) -> Result<PathBuf, PkgError> {
let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
if cache_path.exists() {
return Ok(cache_path);
}
let meta = self.fetch_version_meta(name, version)?;
let tarball_url = if meta.download_url.is_empty() {
format!("{}/packages/{}/{}/download", self.base_url, name, version)
} else {
meta.download_url.clone()
};
let response = self
.authed_get(&tarball_url)
.send()
.map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
if !response.status().is_success() {
return Err(PkgError::Network(format!(
"GET {tarball_url}: status {}",
response.status()
)));
}
let bytes = response
.bytes()
.map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
verify_checksum(&bytes, &meta.checksum)?;
std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
Ok(cache_path)
}
pub fn fetch_package(
&self,
name: &str,
version: &str,
) -> Result<FetchedPackage, PkgError> {
let meta = self.fetch_version_meta(name, version)?;
let cache_path = self.cache_dir.join(format!("{name}-{version}.tar.gz"));
if !cache_path.exists() {
let tarball_url = if meta.download_url.is_empty() {
format!("{}/packages/{}/{}/download", self.base_url, name, version)
} else {
meta.download_url.clone()
};
let response = self
.authed_get(&tarball_url)
.send()
.map_err(|e| PkgError::Network(format!("GET {tarball_url}: {e}")))?;
if !response.status().is_success() {
return Err(PkgError::Network(format!(
"GET {tarball_url}: status {}",
response.status()
)));
}
let bytes = response
.bytes()
.map_err(|e| PkgError::Network(format!("reading {tarball_url}: {e}")))?;
verify_checksum(&bytes, &meta.checksum)?;
std::fs::write(&cache_path, &bytes).map_err(|e| PkgError::Io(e.to_string()))?;
} else {
let bytes = std::fs::read(&cache_path).map_err(|e| PkgError::Io(e.to_string()))?;
verify_checksum(&bytes, &meta.checksum)?;
}
Ok(FetchedPackage {
tarball_path: cache_path,
checksum: normalize_checksum(&meta.checksum),
meta,
})
}
pub fn hydrate(&self, names: &[&str]) -> Result<PackageRegistry, PkgError> {
let mut registry = self.fallback.clone().unwrap_or_default();
for name in names {
match self.fetch_versions(name) {
Ok(versions) => {
for version in &versions.versions {
match self.fetch_version_meta(name, version) {
Ok(meta) => {
let pkg_meta = PackageVersionMeta {
deps: meta.manifest.dependencies,
dep_features: meta.manifest.dep_features,
supported_targets: meta.manifest.supported_targets,
available_features: meta.manifest.available_features,
};
registry.register_with_meta(name, version, pkg_meta)?;
}
Err(PkgError::Network(_)) if self.fallback.is_some() => {}
Err(e) => return Err(e),
}
}
}
Err(PkgError::Network(_)) if self.fallback.is_some() => {}
Err(PkgError::PackageNotFound(_)) if registry.has_package(name) => {
}
Err(e) => return Err(e),
}
}
Ok(registry)
}
}
#[derive(Debug, Clone)]
pub struct FetchedPackage {
pub tarball_path: PathBuf,
pub checksum: String,
pub meta: VersionMetaResponse,
}
#[must_use]
pub fn normalize_checksum(checksum: &str) -> String {
checksum
.strip_prefix("sha256:")
.unwrap_or(checksum)
.to_ascii_lowercase()
}
pub fn verify_checksum(data: &[u8], expected: &str) -> Result<(), PkgError> {
let expected_hex = expected.strip_prefix("sha256:").unwrap_or(expected);
let actual = sha256_hex(data);
if !actual.eq_ignore_ascii_case(expected_hex) {
return Err(PkgError::ChecksumMismatch {
expected: expected_hex.to_string(),
actual,
});
}
Ok(())
}
#[must_use]
pub fn sha256_hex(data: &[u8]) -> String {
let digest = Sha256::digest(data);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RegistriesSection {
pub default: Option<String>,
#[serde(flatten)]
pub named: BTreeMap<String, String>,
}
pub fn parse_registries(project_toml: &str) -> Result<RegistriesSection, PkgError> {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
registries: RegistriesSection,
}
let wrapper: Wrapper = toml::from_str(project_toml)
.map_err(|e| PkgError::ManifestParse(format!("bock.project: {e}")))?;
Ok(wrapper.registries)
}
pub fn default_registry_url(project_dir: &Path) -> Option<String> {
let path = project_dir.join("bock.project");
let content = std::fs::read_to_string(&path).ok()?;
parse_registries(&content).ok()?.default
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
fn tmp_cache() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache");
(dir, path)
}
#[test]
fn sha256_hex_matches_known_vector() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn verify_checksum_accepts_matching_hex() {
let bytes = b"hello world";
let hex = sha256_hex(bytes);
verify_checksum(bytes, &hex).unwrap();
}
#[test]
fn verify_checksum_accepts_sha256_prefix() {
let bytes = b"hello world";
let hex = sha256_hex(bytes);
verify_checksum(bytes, &format!("sha256:{hex}")).unwrap();
}
#[test]
fn verify_checksum_rejects_mismatch() {
let result = verify_checksum(b"hello", "sha256:deadbeef");
assert!(matches!(result, Err(PkgError::ChecksumMismatch { .. })));
}
#[test]
fn fetch_versions_parses_response() {
let mut server = Server::new();
let mock = server
.mock("GET", "/packages/foo")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"versions":["1.0.0","1.1.0"],"latest":"1.1.0"}"#)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), cache).unwrap();
let resp = reg.fetch_versions("foo").unwrap();
assert_eq!(resp.versions, vec!["1.0.0", "1.1.0"]);
assert_eq!(resp.latest, "1.1.0");
mock.assert();
}
#[test]
fn fetch_versions_maps_404_to_package_not_found() {
let mut server = Server::new();
let _mock = server
.mock("GET", "/packages/missing")
.with_status(404)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), cache).unwrap();
let err = reg.fetch_versions("missing").unwrap_err();
assert!(matches!(err, PkgError::PackageNotFound(_)));
}
#[test]
fn fetch_version_meta_parses_manifest() {
let mut server = Server::new();
let body = r#"{
"manifest": {
"dependencies": {"bar": "^1.0"},
"supported_targets": ["js", "rust"],
"available_features": {"json": []}
},
"checksum": "sha256:abc",
"download_url": ""
}"#;
let mock = server
.mock("GET", "/packages/foo/1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), cache).unwrap();
let meta = reg.fetch_version_meta("foo", "1.0.0").unwrap();
assert_eq!(meta.checksum, "sha256:abc");
assert_eq!(meta.manifest.dependencies["bar"], "^1.0");
assert_eq!(
meta.manifest.supported_targets,
Some(vec!["js".into(), "rust".into()])
);
mock.assert();
}
#[test]
fn download_package_verifies_and_caches() {
let mut server = Server::new();
let tarball = b"fake tarball contents";
let checksum = sha256_hex(tarball);
let body = format!(
r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
);
let _meta = server
.mock("GET", "/packages/foo/1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let _download = server
.mock("GET", "/packages/foo/1.0.0/download")
.with_status(200)
.with_body(tarball)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
let path = reg.download_package("foo", "1.0.0").unwrap();
assert!(path.exists());
assert_eq!(std::fs::read(&path).unwrap(), tarball);
let again = reg.download_package("foo", "1.0.0").unwrap();
assert_eq!(again, path);
}
#[test]
fn download_package_rejects_bad_checksum() {
let mut server = Server::new();
let tarball = b"bytes that do not match";
let body = r#"{"manifest":{"dependencies":{}},"checksum":"sha256:deadbeef","download_url":""}"#;
let _meta = server
.mock("GET", "/packages/foo/1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let _download = server
.mock("GET", "/packages/foo/1.0.0/download")
.with_status(200)
.with_body(tarball)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
let err = reg.download_package("foo", "1.0.0").unwrap_err();
assert!(matches!(err, PkgError::ChecksumMismatch { .. }));
assert!(!cache.join("foo-1.0.0.tar.gz").exists());
}
#[test]
fn download_package_honors_custom_download_url() {
let mut server = Server::new();
let tarball = b"custom url payload";
let checksum = sha256_hex(tarball);
let custom_url = format!("{}/mirror/foo-1.0.0.tgz", server.url());
let body = format!(
r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":"{custom_url}"}}"#
);
let _meta = server
.mock("GET", "/packages/foo/1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let _download = server
.mock("GET", "/mirror/foo-1.0.0.tgz")
.with_status(200)
.with_body(tarball)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), &cache).unwrap();
let path = reg.download_package("foo", "1.0.0").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), tarball);
}
#[test]
fn hydrate_populates_registry_from_network() {
let mut server = Server::new();
let _v = server
.mock("GET", "/packages/foo")
.with_status(200)
.with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
.create();
let _m = server
.mock("GET", "/packages/foo/1.0.0")
.with_status(200)
.with_body(
r#"{"manifest":{"dependencies":{"bar":"^1.0"}},"checksum":"sha256:x","download_url":""}"#,
)
.create();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new(server.url(), cache).unwrap();
let registry = reg.hydrate(&["foo"]).unwrap();
assert!(registry.has_package("foo"));
assert_eq!(registry.available_versions("foo").len(), 1);
}
#[test]
fn hydrate_falls_back_when_network_unreachable() {
let mut fallback = PackageRegistry::new();
fallback
.register("foo", "1.0.0", BTreeMap::new())
.unwrap();
let (_tmp, cache) = tmp_cache();
let reg = NetworkRegistry::new("http://127.0.0.1:1/", cache)
.unwrap()
.with_fallback(fallback);
let registry = reg.hydrate(&["foo"]).unwrap();
assert!(registry.has_package("foo"));
}
#[test]
fn parse_registries_reads_default_and_named() {
let project = r#"
[project]
name = "test"
version = "0.1.0"
[registries]
default = "https://registry.bock-lang.dev/api/v1"
internal = "https://bock.company.internal"
"#;
let regs = parse_registries(project).unwrap();
assert_eq!(
regs.default.as_deref(),
Some("https://registry.bock-lang.dev/api/v1")
);
assert_eq!(
regs.named.get("internal").map(String::as_str),
Some("https://bock.company.internal"),
);
}
#[test]
fn parse_registries_missing_section_is_empty() {
let project = r#"
[project]
name = "test"
version = "0.1.0"
"#;
let regs = parse_registries(project).unwrap();
assert!(regs.default.is_none());
assert!(regs.named.is_empty());
}
#[test]
fn default_registry_url_reads_from_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("bock.project"),
"[project]\nname = \"t\"\nversion = \"0.1.0\"\n\n[registries]\ndefault = \"https://example.com/api/v1\"\n",
)
.unwrap();
assert_eq!(
default_registry_url(dir.path()).as_deref(),
Some("https://example.com/api/v1")
);
}
#[test]
fn default_registry_url_missing_file_is_none() {
let dir = tempfile::tempdir().unwrap();
assert!(default_registry_url(dir.path()).is_none());
}
}