use std::fs;
use semver::Version;
use serde::Serialize;
use crate::download::{
download_archive, fetch_capped, http_client, MANIFEST_MAX_BYTES, SIGNATURE_MAX_BYTES,
};
use crate::machine::{self, BundleMeta, State};
use crate::manifest::{self, Manifest};
use crate::runtime::HotUpdate;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateConfig {
pub manifest_url: String,
pub pubkeys: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(
tag = "status",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum UpdateOutcome {
Available { manifest: Manifest },
Staged { seq: u64, version: Version },
UpToDate {
offered: Version,
watermark: Version,
},
Blacklisted { version: Version },
ShellTooOld { required: Version, shell: Version },
AlreadyStaged { seq: u64, version: Version },
}
impl HotUpdate {
pub async fn check(&self, config: &UpdateConfig) -> Result<UpdateOutcome> {
let activation = self.shared.activation()?;
let _serialized = activation.update_lock().lock().await;
let client = http_client()?;
let manifest = fetch_verified_manifest(&client, config).await?;
let state = activation.store().load_state();
Ok(
match gate(&manifest, &state, activation.embedded_version()) {
Some(refusal) => refusal,
None => UpdateOutcome::Available { manifest },
},
)
}
pub async fn check_and_download(
&self,
config: &UpdateConfig,
mut on_progress: impl FnMut(u64, u64) + Send,
) -> Result<UpdateOutcome> {
let activation = self.shared.activation()?;
let _serialized = activation.update_lock().lock().await;
let client = http_client()?;
let manifest = fetch_verified_manifest(&client, config).await?;
let store = activation.store();
if let Some(refusal) = gate(
&manifest,
&store.load_state(),
activation.embedded_version(),
) {
return Ok(refusal);
}
let bundles_dir = store.bundles_dir();
fs::create_dir_all(&bundles_dir)?;
let mut archive_file = tempfile::Builder::new()
.prefix("dl-")
.suffix(".part")
.tempfile_in(&bundles_dir)?;
download_archive(
&client,
&manifest.archive,
archive_file.as_file_mut(),
&mut on_progress,
)
.await?;
let extract_dir = tempfile::Builder::new()
.prefix("extract-")
.tempdir_in(&bundles_dir)?;
let archive_path = archive_file.path().to_path_buf();
let target = extract_dir.path().to_path_buf();
tokio::task::spawn_blocking(move || crate::extract::extract_tar_gz(&archive_path, &target))
.await
.map_err(|e| Error::Io(std::io::Error::other(e)))??;
drop(archive_file);
let seq = store.allocate_seq(&store.load_state());
let final_dir = store.bundle_dir(seq);
fs::rename(extract_dir.keep(), &final_dir)?;
let meta = BundleMeta {
version: manifest.version.clone(),
archive_sha256: manifest.archive.sha256.clone(),
};
match store.update(|state| machine::stage(state, seq, meta))? {
Ok(effects) => {
store.apply_effects(&effects);
log::info!(
"hot-update: bundle v{} staged as seq {seq} for the next launch",
manifest.version
);
Ok(UpdateOutcome::Staged {
seq,
version: manifest.version,
})
}
Err(refused) => {
let _ = fs::remove_dir_all(&final_dir);
Err(Error::StageRefused(refused))
}
}
}
}
async fn fetch_verified_manifest(
client: &reqwest::Client,
config: &UpdateConfig,
) -> Result<Manifest> {
let manifest_bytes = fetch_capped(client, &config.manifest_url, MANIFEST_MAX_BYTES).await?;
let signature_url = format!("{}.minisig", config.manifest_url);
let signature_bytes = fetch_capped(client, &signature_url, SIGNATURE_MAX_BYTES).await?;
let signature = String::from_utf8(signature_bytes)
.map_err(|_| Error::ManifestSignature("signature file is not UTF-8".into()))?;
manifest::verify_and_parse(&manifest_bytes, &signature, &config.pubkeys)
}
fn gate(manifest: &Manifest, state: &State, shell: &Version) -> Option<UpdateOutcome> {
if let Some(watermark) = &state.max_version_seen {
if manifest.version <= *watermark {
return Some(UpdateOutcome::UpToDate {
offered: manifest.version.clone(),
watermark: watermark.clone(),
});
}
}
if state.failed.contains(&manifest.archive.sha256) {
return Some(UpdateOutcome::Blacklisted {
version: manifest.version.clone(),
});
}
if manifest.min_shell_version > *shell {
return Some(UpdateOutcome::ShellTooOld {
required: manifest.min_shell_version.clone(),
shell: shell.clone(),
});
}
if let Some(seq) = state.staged {
if state
.versions
.get(&seq)
.is_some_and(|meta| meta.archive_sha256 == manifest.archive.sha256)
{
return Some(UpdateOutcome::AlreadyStaged {
seq,
version: manifest.version.clone(),
});
}
}
None
}
#[cfg(test)]
mod tests;