use std::time::Duration;
use reqwest::Client;
use semver::Version;
use serde::Deserialize;
use tracing::{debug, info, warn};
use super::manifest::{encode_signed_manifest, ReleaseManifest};
use super::signature::verify_manifest_signature;
use super::UpgradeError;
pub const MAX_MANIFEST_AGE_SECS: u64 = 30 * 24 * 3600;
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub body: Option<String>,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug, Clone)]
pub struct VerifiedRelease {
pub manifest: ReleaseManifest,
pub manifest_json: Vec<u8>,
pub signature: Vec<u8>,
pub gossip_payload: Vec<u8>,
}
pub struct UpgradeMonitor {
repo: String,
current_version: Version,
client: Client,
include_prereleases: bool,
}
impl UpgradeMonitor {
pub fn new(repo: &str, binary_name: &str, current_version: &str) -> Result<Self, String> {
let version =
Version::parse(current_version).map_err(|e| format!("invalid version: {e}"))?;
let client = Client::builder()
.user_agent(format!("{binary_name}/{current_version}"))
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
Ok(Self {
repo: repo.to_string(),
current_version: version,
client,
include_prereleases: false,
})
}
pub fn with_include_prereleases(mut self, include: bool) -> Self {
self.include_prereleases = include;
self
}
pub async fn check_for_updates(&self) -> Result<Option<VerifiedRelease>, UpgradeError> {
let release = self.fetch_latest_github_release().await?;
let latest_version_str = version_from_tag(&release.tag_name);
let latest_version = Version::parse(latest_version_str).map_err(|e| {
UpgradeError::ManifestFetchFailed(format!(
"Invalid version in tag '{}': {e}",
release.tag_name
))
})?;
if latest_version <= self.current_version {
debug!(
current_version = %self.current_version,
"Already on latest version {}",
self.current_version
);
return Ok(None);
}
info!(
current_version = %self.current_version,
new_version = %latest_version,
"New version available: {}",
latest_version
);
match self.fetch_verified_manifest(&release).await {
Ok(verified) => Ok(Some(verified)),
Err(e) => {
warn!(error = %e, "Failed to fetch/verify release manifest, falling back to skip");
Err(e)
}
}
}
pub async fn fetch_current_manifest(&self) -> Result<Option<VerifiedRelease>, UpgradeError> {
let release = self.fetch_latest_github_release().await?;
match self.fetch_verified_manifest(&release).await {
Ok(verified) => Ok(Some(verified)),
Err(e) => {
warn!(error = %e, "Failed to fetch/verify current release manifest");
Err(e)
}
}
}
async fn fetch_latest_github_release(&self) -> Result<GitHubRelease, UpgradeError> {
if self.include_prereleases {
self.fetch_latest_release_including_prereleases()
.await
.map_err(UpgradeError::ManifestFetchFailed)
} else {
let api_url = format!("https://api.github.com/repos/{}/releases/latest", self.repo);
debug!("Checking for updates from: {}", api_url);
self.client
.get(&api_url)
.send()
.await
.map_err(|e| {
UpgradeError::ManifestFetchFailed(format!("GitHub API request failed: {e}"))
})?
.error_for_status()
.map_err(|e| UpgradeError::ManifestFetchFailed(format!("GitHub API error: {e}")))?
.json::<GitHubRelease>()
.await
.map_err(|e| {
UpgradeError::ManifestFetchFailed(format!(
"Failed to parse GitHub release: {e}"
))
})
}
}
pub async fn fetch_verified_manifest(
&self,
release: &GitHubRelease,
) -> Result<VerifiedRelease, UpgradeError> {
info!("Fetching release manifest from GitHub");
let manifest_asset = release
.assets
.iter()
.find(|a| a.name == "release-manifest.json")
.ok_or_else(|| {
UpgradeError::ManifestFetchFailed("release missing release-manifest.json".into())
})?;
let sig_asset = release
.assets
.iter()
.find(|a| a.name == "release-manifest.json.sig")
.ok_or_else(|| {
UpgradeError::ManifestFetchFailed(
"release missing release-manifest.json.sig".into(),
)
})?;
let manifest_bytes = self
.client
.get(&manifest_asset.browser_download_url)
.send()
.await
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?
.error_for_status()
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?
.bytes()
.await
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?;
let sig_bytes = self
.client
.get(&sig_asset.browser_download_url)
.send()
.await
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?
.error_for_status()
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?
.bytes()
.await
.map_err(|e| UpgradeError::ManifestFetchFailed(e.to_string()))?;
verify_manifest_signature(&manifest_bytes, &sig_bytes).map_err(|e| {
warn!(error = %e, "Release manifest signature verification failed");
UpgradeError::ManifestSignatureInvalid
})?;
info!("Release manifest signature verified");
let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|e| UpgradeError::InvalidManifest(e.to_string()))?;
validate_manifest_timestamp(&manifest)?;
let manifest_json = manifest_bytes.to_vec();
let signature = sig_bytes.to_vec();
let gossip_payload = encode_signed_manifest(&manifest_json, &signature);
Ok(VerifiedRelease {
manifest,
manifest_json,
signature,
gossip_payload,
})
}
async fn fetch_latest_release_including_prereleases(&self) -> Result<GitHubRelease, String> {
let api_url = format!(
"https://api.github.com/repos/{}/releases?per_page=1",
self.repo
);
debug!(
"Checking for updates (including prereleases) from: {}",
api_url
);
let releases: Vec<GitHubRelease> = self
.client
.get(&api_url)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {e}"))?
.error_for_status()
.map_err(|e| format!("GitHub API error: {e}"))?
.json()
.await
.map_err(|e| format!("Failed to parse GitHub releases: {e}"))?;
releases
.into_iter()
.next()
.ok_or_else(|| "no releases found".to_string())
}
}
pub fn version_from_tag(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
pub fn validate_manifest_timestamp(manifest: &ReleaseManifest) -> Result<(), UpgradeError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if manifest.timestamp == 0 {
debug!("Manifest has no timestamp, skipping age check");
return Ok(());
}
if now > manifest.timestamp && (now - manifest.timestamp) > MAX_MANIFEST_AGE_SECS {
let age_days = (now - manifest.timestamp) / 86400;
warn!(
manifest_timestamp = manifest.timestamp,
age_days = age_days,
max_age_days = MAX_MANIFEST_AGE_SECS / 86400,
"Rejecting stale manifest: {} days old (max {} days)",
age_days,
MAX_MANIFEST_AGE_SECS / 86400
);
return Err(UpgradeError::InvalidManifest(format!(
"manifest too old: {} days (max {} days)",
age_days,
MAX_MANIFEST_AGE_SECS / 86400
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_from_tag_strips_v() {
assert_eq!(version_from_tag("v0.4.0"), "0.4.0");
assert_eq!(version_from_tag("0.4.0"), "0.4.0");
assert_eq!(version_from_tag("v1.2.3-rc1"), "1.2.3-rc1");
}
}