use std::{
fs::{self, File},
io::{self, BufReader, BufWriter, Write},
path::Path,
};
use soar_config::{config::get_config, repository::Repository};
use soar_dl::{download::Download, http_client::SHARED_AGENT, types::OverwriteMode};
use soar_utils::system::platform;
use tracing::info;
use ureq::http::{
header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH, PRAGMA},
StatusCode,
};
use url::Url;
use crate::{
error::{ErrorContext, RegistryError, Result},
nest::Nest,
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 enum MetadataContent {
SqliteDb(Vec<u8>),
Json(Vec<RemotePackage>),
}
fn construct_nest_url(url: &str) -> Result<String> {
let url = if let Some(repo) = url.strip_prefix("github:") {
format!(
"https://github.com/{}/releases/download/soar-nest/{}.json",
repo,
platform()
)
} else {
url.to_string()
};
Url::parse(&url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?;
Ok(url)
}
pub async fn fetch_nest_metadata(
nest: &Nest,
force: bool,
existing_etag: Option<String>,
) -> Result<Option<(String, MetadataContent)>> {
let config = get_config();
let nests_repo_path = config
.get_repositories_path()
.map_err(|e| {
RegistryError::IoError {
action: "getting repositories path".to_string(),
source: io::Error::other(e.to_string()),
}
})?
.join("nests");
let nest_path = nests_repo_path.join(&nest.name);
let metadata_db = nest_path.join("metadata.db");
if !metadata_db.exists() {
fs::create_dir_all(&nest_path)
.with_context(|| format!("creating directory {}", nest_path.display()))?;
}
let etag = if metadata_db.exists() {
let etag = existing_etag.unwrap_or_default();
if !force && !etag.is_empty() {
let file_info = metadata_db
.metadata()
.with_context(|| format!("reading file metadata from {}", metadata_db.display()))?;
let sync_interval = config.get_nests_sync_interval();
if let Ok(created) = file_info.created() {
if sync_interval >= created.elapsed()?.as_millis() {
return Ok(None);
}
}
}
etag
} else {
String::new()
};
let url = construct_nest_url(&nest.url)?;
let mut req = SHARED_AGENT
.get(&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!("{} [{}]", 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)?;
info!("Fetching nest from {}", url);
let content = resp.into_body().read_to_vec()?;
let metadata_content = process_metadata_content(content, &metadata_db)?;
Ok(Some((etag, metadata_content)))
}
pub async fn fetch_public_key<P: AsRef<Path>>(repo_path: P, pubkey_url: &str) -> Result<()> {
let repo_path = repo_path.as_ref();
let pubkey_file = repo_path.join("minisign.pub");
if pubkey_file.exists() {
return Ok(());
}
info!("Fetching public key from {}", pubkey_url);
Download::new(pubkey_url)
.output(pubkey_file.to_string_lossy().to_string())
.overwrite(OverwriteMode::Force)
.execute()?;
Ok(())
}
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();
let etag = if metadata_db.exists() {
let etag = existing_etag.unwrap_or_default();
if !force && !etag.is_empty() {
let file_info = metadata_db
.metadata()
.with_context(|| format!("reading file metadata from {}", metadata_db.display()))?;
if let Ok(created) = file_info.created() {
if sync_interval >= created.elapsed()?.as_millis() {
return Ok(None);
}
}
}
etag
} else {
String::new()
};
Url::parse(&repo.url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?;
if let Some(ref pubkey_url) = repo.pubkey {
fetch_public_key(&repo_path, pubkey_url).await?;
}
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)?;
info!("Fetching metadata from {}", repo.url);
let content = resp.into_body().read_to_vec()?;
let metadata_content = process_metadata_content(content, &metadata_db)?;
Ok(Some((etag, metadata_content)))
}
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 mut decoder = zstd::Decoder::new(content.as_slice())
.map_err(|e| RegistryError::Custom(format!("creating zstd decoder: {e}")))?;
io::copy(&mut decoder, &mut tmp_file)
.with_context(|| format!("decoding zstd from {tmp_path}"))?;
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(())
}