use serde::Deserialize;
use crate::Error;
use crate::Res;
use crate::checksum::MULTIHASH_CRC64_NVME;
use crate::checksum::MULTIHASH_SHA256_CHUNKED;
use crate::error::RemoteCatalogError;
use crate::io::remote::client::HttpClient;
use quilt_uri::Host;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostChecksums {
Crc64,
Sha256Chunked,
}
impl HostChecksums {
pub fn algorithm_code(&self) -> u64 {
match self {
HostChecksums::Crc64 => MULTIHASH_CRC64_NVME,
HostChecksums::Sha256Chunked => MULTIHASH_SHA256_CHUNKED,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct HostConfig {
pub checksums: HostChecksums,
pub host: Option<Host>,
}
impl Default for HostConfig {
fn default() -> Self {
Self {
checksums: HostChecksums::Sha256Chunked,
host: None,
}
}
}
impl HostConfig {
pub fn default_crc64() -> Self {
Self {
checksums: HostChecksums::Crc64,
host: None,
}
}
pub fn default_sha256_chunked() -> Self {
Self {
checksums: HostChecksums::Sha256Chunked,
host: None,
}
}
}
#[derive(Deserialize)]
struct ConfigResponse {
#[serde(rename = "crc64Checksums")]
crc64_checksums: Option<bool>,
}
pub async fn fetch_host_config(client: &impl HttpClient, host: &Option<Host>) -> Res<HostConfig> {
match host {
Some(host) => {
let url = format!("https://{host}/config.json");
let response: ConfigResponse = client.get(&url, None).await.map_err(|e| {
Error::RemoteCatalog(RemoteCatalogError::HostConfig(format!(
"Failed to fetch config from {host}: {e}"
)))
})?;
let checksums = match response.crc64_checksums {
Some(true) => HostChecksums::Crc64,
Some(false) | None => HostChecksums::Sha256Chunked, };
Ok(HostConfig {
checksums,
host: Some(host.clone()),
})
}
None => Ok(HostConfig::default()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use reqwest::header::HeaderMap;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use test_log::test;
struct MockHttpClient {
responses: std::collections::HashMap<String, Result<String, String>>,
}
impl MockHttpClient {
fn new() -> Self {
Self {
responses: HashMap::new(),
}
}
fn add_response(&mut self, url: String, response: Result<String, String>) {
self.responses.insert(url, response);
}
}
#[async_trait]
impl HttpClient for MockHttpClient {
async fn get<T: DeserializeOwned>(&self, url: &str, _auth_token: Option<&str>) -> Res<T> {
match self.responses.get(url) {
Some(Ok(response_body)) => {
let response: T = serde_json::from_str(response_body)?;
Ok(response)
}
Some(Err(error)) => Err(Error::RemoteCatalog(RemoteCatalogError::HostConfig(
error.clone(),
))),
None => Err(Error::RemoteCatalog(RemoteCatalogError::HostConfig(
format!("No mock response for URL: {url}"),
))),
}
}
async fn head(&self, _url: &str) -> Res<HeaderMap> {
unimplemented!("head not needed for host config tests")
}
async fn post<T: DeserializeOwned>(
&self,
_url: &str,
_form_data: &HashMap<String, String>,
) -> Res<T> {
unimplemented!("post not needed for host config tests")
}
async fn post_json<T: DeserializeOwned, B: serde::Serialize + Send + Sync>(
&self,
_url: &str,
_body: &B,
) -> Res<T> {
unimplemented!("post_json not needed for host config tests")
}
}
#[test(tokio::test)]
async fn test_fetch_host_config_crc64_enabled() -> Res<()> {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Ok(r#"{"crc64Checksums": true}"#.to_string()),
);
let config = fetch_host_config(&client, &Some(Host::default())).await?;
assert_eq!(config.checksums, HostChecksums::Crc64);
Ok(())
}
#[test(tokio::test)]
async fn test_fetch_host_config_crc64_disabled() -> Res<()> {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Ok(r#"{"crc64Checksums": false}"#.to_string()),
);
let config = fetch_host_config(&client, &Some(Host::default())).await?;
assert_eq!(config.checksums, HostChecksums::Sha256Chunked);
Ok(())
}
#[test(tokio::test)]
async fn test_fetch_host_config_crc64_missing() -> Res<()> {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Ok(r"{}".to_string()),
);
let config = fetch_host_config(&client, &Some(Host::default())).await?;
assert_eq!(config.checksums, HostChecksums::Sha256Chunked);
Ok(())
}
#[test(tokio::test)]
async fn test_fetch_host_config_other_fields_ignored() -> Res<()> {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Ok(r#"{"crc64Checksums": true, "mode": "OPEN", "other": "ignored"}"#.to_string()),
);
let config = fetch_host_config(&client, &Some(Host::default())).await?;
assert_eq!(config.checksums, HostChecksums::Crc64);
Ok(())
}
#[test(tokio::test)]
async fn test_fetch_host_config_network_error() {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Err("Network error".to_string()),
);
let result = fetch_host_config(&client, &Some(Host::default())).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Network error"));
}
#[test(tokio::test)]
async fn test_fetch_host_config_invalid_json() {
let mut client = MockHttpClient::new();
client.add_response(
"https://test.quilt.dev/config.json".to_string(),
Ok(r"invalid json".to_string()),
);
let result = fetch_host_config(&client, &Some(Host::default())).await;
assert!(result.is_err());
let error = result.unwrap_err();
match error {
Error::RemoteCatalog(RemoteCatalogError::HostConfig(msg))
if msg.contains("JSON error") =>
{
}
_ => panic!("Expected HostConfig error wrapping JSON error, got: {error:?}"),
}
}
#[test(tokio::test)]
async fn test_fetch_host_config_none() -> Res<()> {
let client = MockHttpClient::new();
let config = fetch_host_config(&client, &None).await?;
assert_eq!(config, HostConfig::default());
Ok(())
}
}