Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ pub(crate) use theme::{
};
#[cfg(test)]
pub(crate) use update::{
asset_name_for_target, expected_asset_download_url, find_expected_checksum, is_newer_version,
validate_download_size, validate_sha256_hex,
asset_name_for_target, build_download_url, expected_asset_download_url,
extract_tag_from_release_url, find_expected_checksum, is_newer_version, validate_download_size,
validate_sha256_hex,
};

fn read_stdin_limited<R: Read>(reader: &mut R, max_bytes: usize) -> Result<String> {
Expand Down
21 changes: 21 additions & 0 deletions src/tests/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,24 @@ fn validate_sha256_hex_accepts_expected_format() {
)
.is_ok());
}

#[test]
fn extract_tag_from_release_url_reads_last_segment() {
let url = "https://github.com/RivoLink/leaf/releases/tag/v1.26.0";
assert_eq!(extract_tag_from_release_url(url).unwrap(), "v1.26.0");
}

#[test]
fn extract_tag_from_release_url_rejects_missing_segment() {
let err = extract_tag_from_release_url("https://github.com/RivoLink/leaf").unwrap_err();
assert!(err.to_string().contains("missing /releases/tag/"));
}

#[test]
fn build_download_url_composes_release_asset_path() {
let url = build_download_url("RivoLink/leaf", "v1.26.0", "leaf-linux-x86_64");
assert_eq!(
url,
"https://github.com/RivoLink/leaf/releases/download/v1.26.0/leaf-linux-x86_64"
);
}
103 changes: 93 additions & 10 deletions src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ use std::{
time::Duration,
};

const GITHUB_API_BASE: &str = "https://api.github.com/repos/RivoLink/leaf";
const GITHUB_API_BASE_FALLBACK: &str = "https://api.github.com/repos/leaf-mg/leaf";
const GITHUB_API_RELEASES_LATEST_PATH: &str = "/releases/latest";
const REPOS: &[&str] = &["RivoLink/leaf", "leaf-mg/leaf"];
const CHECKSUMS_ASSET_NAME: &str = "checksums.txt";
const HTTP_TIMEOUT: Duration = Duration::from_secs(20);

#[cfg(windows)]
const INSTALLER_NAME: &str = "install.ps1";

#[cfg(not(windows))]
const INSTALLER_NAME: &str = "install.sh";

#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
Expand All @@ -32,7 +36,7 @@ pub(crate) fn run_update() -> Result<()> {

let current_version = env!("CARGO_PKG_VERSION");
let asset_name = current_asset_name()?;
let release = fetch_latest_release()?;
let release = fetch_latest_release(asset_name)?;
let latest_version = normalize_version_tag(&release.tag_name);

if !is_newer_version(current_version, latest_version)? {
Expand Down Expand Up @@ -88,18 +92,35 @@ pub(crate) fn asset_name_for_target(os: &str, arch: &str) -> Option<&'static str
}
}

fn fetch_latest_release() -> Result<GithubRelease> {
match fetch_release_from(GITHUB_API_BASE) {
Ok(release) => Ok(release),
Err(_) => fetch_release_from(GITHUB_API_BASE_FALLBACK),
fn fetch_latest_release(asset_name: &str) -> Result<GithubRelease> {
let mut last_err: Option<anyhow::Error> = None;

for repo in REPOS {
match fetch_release_from_api(repo) {
Ok(release) => return Ok(release),
Err(err) => last_err = Some(err),
}
}
for repo in REPOS {
match fetch_release_from_redirect(repo, asset_name) {
Ok(release) => return Ok(release),
Err(err) => last_err = Some(err),
}
}

let last = last_err.unwrap_or_else(|| anyhow::anyhow!("Unable to fetch latest leaf release"));
Err(last.context(format!(
"All GitHub sources failed. Try reinstalling via {INSTALLER_NAME}."
)))
}

fn fetch_release_from(base_url: &str) -> Result<GithubRelease> {
fn fetch_release_from_api(repo: &str) -> Result<GithubRelease> {
let client = http_client()?;

let response = client
.get(format!("{base_url}{GITHUB_API_RELEASES_LATEST_PATH}"))
.get(format!(
"https://api.github.com/repos/{repo}/releases/latest"
))
.header(reqwest::header::USER_AGENT, "leaf-updater")
.send()
.context("Cannot reach GitHub releases API")?;
Expand All @@ -120,6 +141,59 @@ fn fetch_release_from(base_url: &str) -> Result<GithubRelease> {
.context("Cannot parse GitHub release metadata")
}

fn fetch_release_from_redirect(repo: &str, asset_name: &str) -> Result<GithubRelease> {
let client = http_client_no_redirect()?;

let response = client
.head(format!("https://github.com/{repo}/releases/latest"))
.header(reqwest::header::USER_AGENT, "leaf-updater")
.send()
.context("Cannot reach GitHub releases page")?;

let status = response.status();
if !status.is_redirection() {
bail!("GitHub releases page returned unexpected HTTP {status}");
}

let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow::anyhow!("GitHub releases redirect missing Location header"))?;

let tag_name = extract_tag_from_release_url(location)?.to_string();
let assets = build_synthetic_assets(repo, &tag_name, asset_name);

Ok(GithubRelease { tag_name, assets })
}

pub(crate) fn extract_tag_from_release_url(url: &str) -> Result<&str> {
let marker = "/releases/tag/";
let idx = url
.rfind(marker)
.ok_or_else(|| anyhow::anyhow!("Release URL missing /releases/tag/ segment: {url}"))?;
let tail = &url[idx + marker.len()..];
let tag = tail.split(['/', '?', '#']).next().unwrap_or("");
if tag.is_empty() {
bail!("Release URL contains an empty tag: {url}");
}
Ok(tag)
}

pub(crate) fn build_download_url(repo: &str, tag: &str, asset_name: &str) -> String {
format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}")
}

fn build_synthetic_assets(repo: &str, tag: &str, asset_name: &str) -> Vec<GithubAsset> {
[asset_name, CHECKSUMS_ASSET_NAME]
.into_iter()
.map(|name| GithubAsset {
name: name.to_string(),
browser_download_url: build_download_url(repo, tag, name),
})
.collect()
}

pub(crate) fn expected_asset_download_url<'a>(
tag_name: &str,
assets: &'a [impl AsRefAsset],
Expand Down Expand Up @@ -205,6 +279,15 @@ fn http_client() -> Result<Client> {
Ok(client)
}

fn http_client_no_redirect() -> Result<Client> {
let client = Client::builder()
.timeout(HTTP_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.context("Cannot initialize HTTP client")?;
Ok(client)
}

fn validate_download_response(
status: reqwest::StatusCode,
content_length: Option<u64>,
Expand Down
Loading