use std::time::Duration;
use self_update::backends::gitlab;
use self_update::http_client::{HeaderMap, UreqClient};
pub const GITLAB_HOST: &str = "https://gitlab.com";
pub const REPO_OWNER: &str = "vPierre";
pub const REPO_NAME: &str = "ndaal_public_csaf_crud";
pub const SHA256SUMS_ASSET: &str = "SHA256SUMS";
pub const NO_SELF_UPDATE_ENV: &str = "CSAF_NO_SELF_UPDATE";
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
pub const KNOWN_TARGETS: [&str; 6] = [
"aarch64-apple-darwin",
"aarch64-pc-windows-msvc",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-gnu",
"x86_64-unknown-linux-gnu",
];
#[derive(Debug, thiserror::Error)]
pub enum UpdateError {
#[error("no release asset for target triple `{triple}` (this build ships: {known})")]
NoAssetForTarget {
triple: String,
known: String,
},
#[error(
"`{SHA256SUMS_ASSET}` in release {tag} has no entry for `{asset}` — refusing to install an unverified artifact"
)]
ChecksumMissing {
tag: String,
asset: String,
},
#[error(
"refusing to use remote asset name `{0}`: contains a path separator, `..`, a NUL byte, or is absolute"
)]
UnsafeAssetName(String),
#[error("could not read `{SHA256SUMS_ASSET}` from {url}: {source}")]
ManifestRead {
url: String,
source: std::io::Error,
},
#[error(transparent)]
SelfUpdate(#[from] self_update::errors::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateOutcome {
UpToDate {
current: String,
},
Available {
current: String,
latest: String,
},
Updated {
from: String,
to: String,
},
Unreachable {
reason: String,
},
DisabledByPolicy,
}
impl UpdateOutcome {
#[must_use]
pub const fn exit_code(&self) -> i32 {
match *self {
Self::UpToDate { .. } | Self::Updated { .. } | Self::Unreachable { .. } => 0,
Self::DisabledByPolicy => 3,
Self::Available { .. } => 10,
}
}
#[must_use]
pub fn message(&self, bin_name: &str, triple: &str) -> String {
match *self {
Self::UpToDate { ref current } => {
format!("{bin_name} {current} ({triple}): up to date")
},
Self::Available {
ref current,
ref latest,
} => format!("{bin_name} {current} ({triple}): update available -> {latest}"),
Self::Updated { ref from, ref to } => {
format!("{bin_name} ({triple}): updated {from} -> {to}")
},
Self::Unreachable { ref reason } => {
format!("could not reach the update host ({reason}); nothing was changed")
},
Self::DisabledByPolicy => format!(
"self-update is disabled by policy (--no-self-update / {NO_SELF_UPDATE_ENV})"
),
}
}
}
#[must_use]
pub fn asset_name(bin_name: &str, version: &str, triple: &str) -> String {
format!("{bin_name}-v{version}-{triple}.tar.gz")
}
#[must_use]
pub fn bin_path_in_archive(bin_name: &str, version: &str, triple: &str) -> String {
format!("{bin_name}-v{version}-{triple}/{bin_name}")
}
#[must_use]
pub fn is_safe_asset_name(name: &str) -> bool {
!name.is_empty()
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0')
&& !name.contains("..")
}
#[must_use]
pub fn parse_sha256sums(body: &str, asset: &str) -> Option<String> {
if asset.is_empty() {
return None;
}
body.lines()
.filter_map(|line| split_sums_line(line.trim()))
.find(|&(_, name)| name == asset)
.map(|(digest, _)| digest.to_ascii_lowercase())
}
fn split_sums_line(line: &str) -> Option<(&str, &str)> {
if line.is_empty() || line.starts_with('#') {
return None;
}
let (digest, rest) = line.split_once(char::is_whitespace)?;
if digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let rest = rest.trim_start();
let name = rest.strip_prefix('*').unwrap_or(rest);
if name.is_empty() {
None
} else {
Some((digest, name))
}
}
#[must_use]
pub fn download_url(tag: &str, asset: &str) -> String {
format!("{GITLAB_HOST}/{REPO_OWNER}/{REPO_NAME}/-/releases/{tag}/downloads/{asset}")
}
#[must_use]
pub fn is_newer(current: &str, latest: &str) -> bool {
self_update::version::bump_is_greater(current, latest).unwrap_or(false)
}
#[must_use]
pub fn env_opt_out() -> bool {
std::env::var(NO_SELF_UPDATE_ENV).is_ok_and(|v| env_flag_on(&v))
}
#[must_use]
pub fn env_flag_on(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[must_use]
pub fn target_triple() -> &'static str {
self_update::get_target()
}
pub type Transport = Option<std::sync::Arc<dyn self_update::http_client::HttpClient>>;
#[must_use]
pub fn check(current_version: &str) -> UpdateOutcome {
check_with(current_version, None)
}
#[must_use]
pub fn check_with(current_version: &str, transport: Transport) -> UpdateOutcome {
let releases = match fetch_release_list(current_version, transport) {
Ok(r) => r,
Err(e) => {
return UpdateOutcome::Unreachable {
reason: e.to_string(),
};
},
};
let Some(latest) = releases.latest().map(|r| r.version().to_owned()) else {
return UpdateOutcome::Unreachable {
reason: "the project has published no releases".to_owned(),
};
};
if is_newer(current_version, &latest) {
UpdateOutcome::Available {
current: current_version.to_owned(),
latest,
}
} else {
UpdateOutcome::UpToDate {
current: current_version.to_owned(),
}
}
}
fn fetch_release_list(
current_version: &str,
transport: Transport,
) -> self_update::errors::Result<self_update::Releases> {
let mut builder = gitlab::Update::configure();
builder
.host(GITLAB_HOST)
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name(REPO_NAME)
.current_version(current_version)
.timeout(HTTP_TIMEOUT);
if let Some(client) = transport {
builder.http_client(client);
}
builder.build()?.get_latest_release()
}
pub fn perform(bin_name: &str, current_version: &str) -> Result<UpdateOutcome, UpdateError> {
perform_with(bin_name, current_version, target_triple(), None)
}
pub fn perform_with(
bin_name: &str,
current_version: &str,
triple: &str,
transport: Transport,
) -> Result<UpdateOutcome, UpdateError> {
if !KNOWN_TARGETS.contains(&triple) {
return Err(UpdateError::NoAssetForTarget {
triple: triple.to_owned(),
known: KNOWN_TARGETS.join(", "),
});
}
let releases = match fetch_release_list(current_version, transport) {
Ok(r) => r,
Err(e) => {
return Ok(UpdateOutcome::Unreachable {
reason: e.to_string(),
});
},
};
let Some(latest) = releases.latest().map(|r| r.version().to_owned()) else {
return Ok(UpdateOutcome::Unreachable {
reason: "the project has published no releases".to_owned(),
});
};
if !is_newer(current_version, &latest) {
return Ok(UpdateOutcome::UpToDate {
current: current_version.to_owned(),
});
}
install(bin_name, current_version, &latest, triple)
}
pub fn prepare_install(
bin_name: &str,
latest: &str,
triple: &str,
) -> Result<(String, String), UpdateError> {
let asset = asset_name(bin_name, latest, triple);
if !is_safe_asset_name(&asset) {
return Err(UpdateError::UnsafeAssetName(asset));
}
Ok((format!("v{latest}"), asset))
}
fn install(
bin_name: &str,
current_version: &str,
latest: &str,
triple: &str,
) -> Result<UpdateOutcome, UpdateError> {
let (tag, asset) = prepare_install(bin_name, latest, triple)?;
let digest = fetch_digest(&tag, &asset)?;
gitlab::Update::configure()
.host(GITLAB_HOST)
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name(bin_name)
.target(triple)
.bin_path_in_archive(bin_path_in_archive(bin_name, latest, triple))
.current_version(current_version)
.verify_checksum(self_update::Checksum::Sha256(digest))
.unattended()
.show_download_progress(false)
.timeout(HTTP_TIMEOUT)
.build()?
.update()?;
Ok(UpdateOutcome::Updated {
from: current_version.to_owned(),
to: latest.to_owned(),
})
}
fn fetch_digest(tag: &str, asset: &str) -> Result<String, UpdateError> {
fetch_digest_with(tag, asset, None)
}
pub fn fetch_digest_with(
tag: &str,
asset: &str,
transport: Transport,
) -> Result<String, UpdateError> {
let url = download_url(tag, SHA256SUMS_ASSET);
let client = transport.unwrap_or_else(|| std::sync::Arc::new(UreqClient::default()));
let response = client.get(&url, &HeaderMap::new(), Some(HTTP_TIMEOUT))?;
let mut body = String::new();
std::io::Read::read_to_string(&mut response.body(), &mut body).map_err(|source| {
UpdateError::ManifestRead {
url: url.clone(),
source,
}
})?;
parse_sha256sums(&body, asset).ok_or_else(|| UpdateError::ChecksumMissing {
tag: tag.to_owned(),
asset: asset.to_owned(),
})
}