use std::{
fs::{self, File},
io::{self, BufReader, BufWriter, Write},
path::Path,
};
use minisign_verify::{PublicKey, Signature};
use soar_config::repository::Repository;
use soar_dl::http_client::SHARED_AGENT;
use tracing::debug;
use ureq::http::{
header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH, PRAGMA},
StatusCode,
};
use url::Url;
use crate::{
error::{ErrorContext, RegistryError, Result},
package::RemotePackage,
};
pub const SQLITE_MAGIC_BYTES: [u8; 4] = [0x53, 0x51, 0x4c, 0x69];
pub const ZST_MAGIC_BYTES: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
pub const MAX_METADATA_SIZE: u64 = 256 * 1024 * 1024;
pub enum MetadataContent {
SqliteDb(Vec<u8>),
Json(Vec<RemotePackage>),
}
pub async fn fetch_metadata(
repo: &Repository,
force: bool,
existing_etag: Option<String>,
) -> Result<Option<(String, MetadataContent)>> {
let repo_path = repo.get_path().map_err(|e| {
RegistryError::IoError {
action: "getting repository path".to_string(),
source: io::Error::other(e.to_string()),
}
})?;
let metadata_db = repo_path.join("metadata.db");
if !metadata_db.exists() {
fs::create_dir_all(&repo_path)
.with_context(|| format!("creating directory {}", repo_path.display()))?;
}
let sync_interval = repo.sync_interval();
if metadata_db.exists() && !force {
if sync_interval == u128::MAX {
return Ok(None);
}
let file_info = metadata_db
.metadata()
.with_context(|| format!("reading file metadata from {}", metadata_db.display()))?;
if let Ok(modified) = file_info.modified() {
if sync_interval >= modified.elapsed()?.as_millis() {
return Ok(None);
}
}
}
let etag = if metadata_db.exists() {
existing_etag.unwrap_or_default()
} else {
String::new()
};
let parsed_url =
Url::parse(&repo.url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?;
if parsed_url.scheme() != "https" {
return Err(RegistryError::InsecureUrl(repo.url.clone()));
}
let mut req = SHARED_AGENT
.get(&repo.url)
.header(CACHE_CONTROL, "no-cache")
.header(PRAGMA, "no-cache");
if !etag.is_empty() {
req = req.header(IF_NONE_MATCH, etag);
}
let resp = req
.call()
.map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?;
if resp.status() == StatusCode::NOT_MODIFIED {
return Ok(None);
}
if !resp.status().is_success() {
let msg = format!("{} [{}]", repo.url, resp.status());
return Err(RegistryError::FailedToFetchRemote(msg));
}
let etag = resp
.headers()
.get(ETAG)
.and_then(|h| h.to_str().ok())
.map(String::from)
.ok_or(RegistryError::MissingEtag)?;
debug!("Fetching metadata from {}", repo.url);
let content = resp
.into_body()
.into_with_config()
.limit(MAX_METADATA_SIZE)
.read_to_vec()?;
verify_metadata_signature(repo, &content)?;
let metadata_content = process_metadata_content(content, &metadata_db)?;
Ok(Some((etag, metadata_content)))
}
fn verify_metadata_signature(repo: &Repository, content: &[u8]) -> Result<()> {
if !repo.signature_verification() {
return Ok(());
}
let pubkey = repo.pubkey.as_deref().ok_or_else(|| {
RegistryError::MetadataSignatureInvalid {
repo: repo.name.clone(),
reason: "signature verification is enabled but no public key is configured".to_string(),
}
})?;
let sig_url = format!("{}.sig", repo.url);
let sig_text = fetch_signature_text(&sig_url).map_err(|reason| {
RegistryError::MetadataSignatureMissing {
repo: repo.name.clone(),
reason,
}
})?;
let public_key = PublicKey::from_base64(pubkey.trim()).map_err(|err| {
RegistryError::MetadataSignatureInvalid {
repo: repo.name.clone(),
reason: format!("invalid public key: {err}"),
}
})?;
let signature = Signature::decode(&sig_text).map_err(|err| {
RegistryError::MetadataSignatureInvalid {
repo: repo.name.clone(),
reason: format!("malformed signature: {err}"),
}
})?;
public_key
.verify(content, &signature, true)
.map_err(|err| {
RegistryError::MetadataSignatureInvalid {
repo: repo.name.clone(),
reason: err.to_string(),
}
})?;
debug!("Verified metadata signature for {}", repo.name);
Ok(())
}
fn fetch_signature_text(url: &str) -> std::result::Result<String, String> {
let resp = SHARED_AGENT
.get(url)
.header(CACHE_CONTROL, "no-cache")
.header(PRAGMA, "no-cache")
.call()
.map_err(|err| err.to_string())?;
if !resp.status().is_success() {
return Err(format!("{} [{}]", url, resp.status()));
}
resp.into_body()
.read_to_string()
.map_err(|err| err.to_string())
}
pub fn process_metadata_content(
content: Vec<u8>,
metadata_db_path: &Path,
) -> Result<MetadataContent> {
if content.len() < 4 {
return Err(RegistryError::MetadataTooShort);
}
if content[..4] == ZST_MAGIC_BYTES {
let tmp_path = format!("{}.part", metadata_db_path.display());
let mut tmp_file = File::create(&tmp_path)
.with_context(|| format!("creating temporary file {tmp_path}"))?;
let decoder = zstd::Decoder::new(content.as_slice())
.map_err(|e| RegistryError::Custom(format!("creating zstd decoder: {e}")))?;
let mut limited = io::Read::take(decoder, MAX_METADATA_SIZE + 1);
let written = io::copy(&mut limited, &mut tmp_file)
.with_context(|| format!("decoding zstd from {tmp_path}"))?;
if written > MAX_METADATA_SIZE {
drop(tmp_file);
let _ = fs::remove_file(&tmp_path);
return Err(RegistryError::MetadataTooLarge {
limit: MAX_METADATA_SIZE,
});
}
let magic_bytes = soar_utils::fs::read_file_signature(&tmp_path, 4).map_err(|e| {
RegistryError::IoError {
action: format!("reading signature from {tmp_path}"),
source: io::Error::other(e.to_string()),
}
})?;
if magic_bytes == SQLITE_MAGIC_BYTES {
let db_content = fs::read(&tmp_path)
.with_context(|| format!("reading temporary file {tmp_path}"))?;
fs::remove_file(&tmp_path)
.with_context(|| format!("removing temporary file {tmp_path}"))?;
Ok(MetadataContent::SqliteDb(db_content))
} else {
let tmp_file = File::open(&tmp_path)
.with_context(|| format!("opening temporary file {tmp_path}"))?;
let reader = BufReader::new(tmp_file);
let metadata: Vec<RemotePackage> = serde_json::from_reader(reader)?;
fs::remove_file(&tmp_path)
.with_context(|| format!("removing temporary file {tmp_path}"))?;
Ok(MetadataContent::Json(metadata))
}
} else if content[..4] == SQLITE_MAGIC_BYTES {
Ok(MetadataContent::SqliteDb(content))
} else {
let metadata: Vec<RemotePackage> = serde_json::from_slice(&content)?;
Ok(MetadataContent::Json(metadata))
}
}
pub fn write_metadata_db<P: AsRef<Path>>(content: &[u8], path: P) -> Result<()> {
let path = path.as_ref();
let mut writer = BufWriter::new(
File::create(path).with_context(|| format!("creating metadata file {}", path.display()))?,
);
writer
.write_all(content)
.with_context(|| format!("writing to metadata file {}", path.display()))?;
Ok(())
}