use crate::hash::encode_hex;
use crate::runtime_state::RuntimeState;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{Cursor, Read, Write};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
use zip::ZipArchive;
const GITHUB_API_URL: &str =
"https://api.github.com/repos/mason-org/mason-registry/releases/latest";
const REGISTRY_ASSET_NAME: &str = "registry.json.zip";
const REGISTRY_FRESHNESS_THRESHOLD: Duration = Duration::from_hours(24 * 30);
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub(super) struct RegistryMetadata {
pub(super) release_tag: String,
pub(super) refreshed_at_epoch_seconds: u64,
pub(super) digest: Option<String>,
}
impl RegistryMetadata {
pub(super) fn is_fresh_at(&self, now_epoch_seconds: u64) -> bool {
now_epoch_seconds.saturating_sub(self.refreshed_at_epoch_seconds)
<= REGISTRY_FRESHNESS_THRESHOLD.as_secs()
}
}
#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
assets: Vec<GithubReleaseAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAsset {
name: String,
browser_download_url: String,
digest: Option<String>,
}
pub(super) fn ensure_registry_cache(state: &RuntimeState) -> Result<(), String> {
let registry_json_path = state.registry_json_path();
let metadata_path = state.registry_metadata_path();
let now_epoch_seconds = unix_timestamp_now()?;
if let Some(metadata) = read_registry_metadata(&metadata_path)?
&& metadata.is_fresh_at(now_epoch_seconds)
&& registry_json_path.is_file()
{
return Ok(());
}
refresh_registry_cache(state, now_epoch_seconds)
}
fn refresh_registry_cache(state: &RuntimeState, now_epoch_seconds: u64) -> Result<(), String> {
let client = Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|error| format!("failed to create HTTP client: {error}"))?;
let release = fetch_latest_release(&client)?;
let asset = release
.assets
.into_iter()
.find(|asset| asset.name == REGISTRY_ASSET_NAME)
.ok_or_else(|| "Mason registry release does not include registry.json.zip".to_string())?;
let metadata_path = state.registry_metadata_path();
let registry_json_path = state.registry_json_path();
if let Some(existing) = read_registry_metadata(&metadata_path)?
&& existing.release_tag == release.tag_name
&& registry_json_path.is_file()
{
let refreshed = RegistryMetadata {
release_tag: existing.release_tag,
refreshed_at_epoch_seconds: now_epoch_seconds,
digest: existing.digest,
};
write_json_file(&metadata_path, &refreshed)?;
return Ok(());
}
let archive_bytes = download_bytes(&client, &asset.browser_download_url)?;
verify_sha256(&archive_bytes, asset.digest.as_deref())?;
let registry_bytes = unpack_registry_json(&archive_bytes)?;
write_bytes_file(®istry_json_path, ®istry_bytes)?;
write_json_file(
&metadata_path,
&RegistryMetadata {
release_tag: release.tag_name,
refreshed_at_epoch_seconds: now_epoch_seconds,
digest: asset.digest,
},
)?;
Ok(())
}
fn fetch_latest_release(client: &Client) -> Result<GithubRelease, String> {
let response = client
.get(GITHUB_API_URL)
.header("Accept", "application/vnd.github+json")
.send()
.map_err(|error| format!("failed to contact GitHub for Mason registry metadata: {error}"))?
.error_for_status()
.map_err(|error| format!("failed to fetch Mason registry metadata: {error}"))?;
response
.json::<GithubRelease>()
.map_err(|error| format!("failed to parse Mason registry metadata: {error}"))
}
fn download_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
let mut response = client
.get(url)
.send()
.map_err(|error| format!("failed to download Mason registry archive: {error}"))?
.error_for_status()
.map_err(|error| format!("failed to download Mason registry archive: {error}"))?;
let mut bytes = Vec::new();
response
.read_to_end(&mut bytes)
.map_err(|error| format!("failed to read Mason registry archive: {error}"))?;
Ok(bytes)
}
fn verify_sha256(bytes: &[u8], digest: Option<&str>) -> Result<(), String> {
let Some(digest) = digest else {
return Ok(());
};
let expected = digest
.strip_prefix("sha256:")
.ok_or_else(|| format!("unsupported Mason registry digest format: {digest}"))?;
let actual = encode_hex(&Sha256::digest(bytes));
if actual == expected {
Ok(())
} else {
Err("downloaded Mason registry archive failed integrity verification".to_string())
}
}
fn unpack_registry_json(archive_bytes: &[u8]) -> Result<Vec<u8>, String> {
let cursor = Cursor::new(archive_bytes);
let mut archive = ZipArchive::new(cursor)
.map_err(|error| format!("failed to open Mason registry archive: {error}"))?;
let mut file = archive
.by_name("registry.json")
.map_err(|error| format!("failed to read registry.json from Mason archive: {error}"))?;
let mut registry_bytes = Vec::new();
file.read_to_end(&mut registry_bytes)
.map_err(|error| format!("failed to unpack Mason registry data: {error}"))?;
Ok(registry_bytes)
}
fn read_registry_metadata(path: &Path) -> Result<Option<RegistryMetadata>, String> {
match fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents)
.map(Some)
.map_err(|error| format!("failed to parse {}: {error}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("failed to read {}: {error}", path.display())),
}
}
fn write_bytes_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Err(format!(
"failed to determine parent directory for {}",
path.display()
));
};
fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
let mut temp = NamedTempFile::new_in(parent).map_err(|error| {
format!(
"failed to create temporary file in {}: {error}",
parent.display()
)
})?;
temp.write_all(bytes)
.map_err(|error| format!("failed to write {}: {error}", path.display()))?;
temp.persist(path)
.map_err(|error| format!("failed to persist {}: {error}", path.display()))?;
Ok(())
}
fn write_json_file<T>(path: &Path, value: &T) -> Result<(), String>
where
T: Serialize,
{
let bytes = serde_json::to_vec_pretty(value)
.map_err(|error| format!("failed to serialize {}: {error}", path.display()))?;
write_bytes_file(path, &bytes)
}
fn unix_timestamp_now() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|error| format!("failed to read system clock: {error}"))
}