use crate::commands::version::ReleaseLatestPolicy;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
struct GithubReleaseRequest {
tag_name: String,
target_commitish: String,
name: String,
body: String,
draft: bool,
prerelease: bool,
make_latest: String,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseResponse {
id: u64,
html_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct GithubReleaseTagResponse {
pub(crate) id: u64,
pub(crate) html_url: Option<String>,
pub(crate) prerelease: Option<bool>,
pub(crate) draft: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAssetResponse {
id: u64,
name: String,
}
#[derive(Debug, Serialize)]
struct GithubReleaseUpdateRequest {
name: String,
body: String,
draft: bool,
prerelease: bool,
make_latest: String,
}
#[derive(Debug, Clone)]
pub(crate) struct GithubReleaseInput {
pub(crate) owner: String,
pub(crate) repo: String,
pub(crate) token: String,
pub(crate) tag_name: String,
pub(crate) target_commitish: String,
pub(crate) title: String,
pub(crate) notes: String,
pub(crate) draft: bool,
pub(crate) prerelease: bool,
pub(crate) latest_policy: ReleaseLatestPolicy,
}
#[derive(Debug, Clone)]
pub(crate) struct GithubReleaseResult {
pub(crate) id: u64,
pub(crate) html_url: String,
}
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
const GITHUB_UPLOADS_BASE_URL: &str = "https://uploads.github.com";
const GITHUB_RELEASE_ACCEPT_HEADER: &str = "application/vnd.github+json";
const GITHUB_RELEASE_USER_AGENT: &str = "xbp-cli-release/1.0";
const GITHUB_RELEASE_UPLOAD_REDIRECT_LIMIT: usize = 5;
pub(crate) async fn create_github_release(
input: &GithubReleaseInput,
) -> Result<GithubReleaseResult, String> {
let endpoint: reqwest::Url = github_release_endpoint(&input.owner, &input.repo)
.map_err(|e| format!("Failed to build GitHub create release URL: {}", e))?;
let payload: GithubReleaseRequest = GithubReleaseRequest {
tag_name: input.tag_name.clone(),
target_commitish: input.target_commitish.clone(),
name: input.title.clone(),
body: input.notes.clone(),
draft: input.draft,
prerelease: input.prerelease,
make_latest: input.latest_policy.as_github_api_value().to_string(),
};
let client: reqwest::Client = reqwest::Client::new();
let request = client.post(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to call GitHub release API: {}", e))?;
if !response.status().is_success() {
let status: reqwest::StatusCode = response.status();
let body: String = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release API failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release response: {}", e))?;
Ok(GithubReleaseResult {
id: payload.id,
html_url: payload
.html_url
.unwrap_or_else(|| github_release_html_url(&input.owner, &input.repo, &input.tag_name)),
})
}
pub(crate) async fn get_github_release_by_tag(
input: &GithubReleaseInput,
) -> Result<Option<GithubReleaseTagResponse>, String> {
let endpoint: reqwest::Url =
github_release_by_tag_endpoint(&input.owner, &input.repo, &input.tag_name)
.map_err(|e| format!("Failed to build GitHub release lookup URL: {}", e))?;
let client: reqwest::Client = reqwest::Client::new();
let request = client.get(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| format!("Failed to query GitHub release by tag: {}", e))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
let status: reqwest::StatusCode = response.status();
let body: String = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release lookup failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseTagResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release lookup response: {}", e))?;
Ok(Some(payload))
}
pub(crate) async fn update_github_release(
input: &GithubReleaseInput,
release_id: u64,
) -> Result<GithubReleaseResult, String> {
let endpoint: reqwest::Url =
github_release_update_endpoint(&input.owner, &input.repo, release_id)
.map_err(|e| format!("Failed to build GitHub release update URL: {}", e))?;
let payload: GithubReleaseUpdateRequest = GithubReleaseUpdateRequest {
name: input.title.clone(),
body: input.notes.clone(),
draft: input.draft,
prerelease: input.prerelease,
make_latest: input.latest_policy.as_github_api_value().to_string(),
};
let client: reqwest::Client = reqwest::Client::new();
let request = client.patch(endpoint);
let response: reqwest::Response = github_release_headers(request, &input.token)
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to call GitHub release update API: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release update failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let payload: GithubReleaseResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release update response: {}", e))?;
Ok(GithubReleaseResult {
id: payload.id,
html_url: payload
.html_url
.unwrap_or_else(|| github_release_html_url(&input.owner, &input.repo, &input.tag_name)),
})
}
pub(crate) async fn upload_github_release_asset(
input: &GithubReleaseInput,
release_id: u64,
asset_path: &Path,
) -> Result<(), String> {
let asset_name = asset_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("Invalid release asset path: {}", asset_path.display()))?;
let asset_bytes = fs::read(asset_path).map_err(|e| {
format!(
"Failed to read release asset {}: {}",
asset_path.display(),
e
)
})?;
let content_type = github_release_asset_content_type(asset_path);
if let Some(existing_asset_id) =
find_github_release_asset_id(input, release_id, asset_name).await?
{
delete_github_release_asset(input, existing_asset_id, asset_name).await?;
}
let mut endpoint =
github_release_asset_upload_endpoint(&input.owner, &input.repo, release_id, asset_name)
.map_err(|e| format!("Failed to build GitHub release asset upload URL: {}", e))?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to build GitHub upload client: {}", e))?;
for redirect_count in 0..=GITHUB_RELEASE_UPLOAD_REDIRECT_LIMIT {
let request = github_release_asset_upload_request(
&client,
endpoint.clone(),
&input.token,
content_type,
&asset_bytes,
);
let response = request.send().await.map_err(|e| {
format!(
"Failed to upload GitHub release asset {}: {}",
asset_name, e
)
})?;
if response.status().is_success() {
return Ok(());
}
if let Some(redirect_target) = github_release_asset_upload_redirect_target(
response.status(),
response.headers(),
&endpoint,
)? {
if redirect_count == GITHUB_RELEASE_UPLOAD_REDIRECT_LIMIT {
return Err(format!(
"GitHub release asset upload failed for {}: too many redirects while uploading to {}",
asset_name, endpoint
));
}
endpoint = redirect_target;
continue;
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset upload failed for {} with status {}: {}",
asset_name,
status,
github_api_error_message(&body)
));
}
Err(format!(
"GitHub release asset upload failed for {}: redirect loop exhausted",
asset_name
))
}
fn github_api_error_message(body: &str) -> String {
let parsed: Option<JsonValue> = serde_json::from_str::<JsonValue>(body).ok();
let message: String = parsed
.as_ref()
.and_then(|value| value.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown GitHub API error")
.to_string();
let details: String = parsed
.as_ref()
.and_then(|value| value.get("errors"))
.map(|value| value.to_string())
.unwrap_or_default();
if details.is_empty() {
message
} else {
format!("{} ({})", message, details)
}
}
pub(crate) fn github_release_endpoint(owner: &str, repo: &str) -> Result<reqwest::Url, String> {
github_api_url(&["repos", owner, repo, "releases"])
}
pub(crate) fn github_release_by_tag_endpoint(
owner: &str,
repo: &str,
tag: &str,
) -> Result<reqwest::Url, String> {
github_api_url(&["repos", owner, repo, "releases", "tags", tag])
}
pub(crate) fn github_release_update_endpoint(
owner: &str,
repo: &str,
release_id: u64,
) -> Result<reqwest::Url, String> {
let release_id_value: String = release_id.to_string();
github_api_url(&["repos", owner, repo, "releases", &release_id_value])
}
pub(crate) fn github_release_assets_endpoint(
owner: &str,
repo: &str,
release_id: u64,
) -> Result<reqwest::Url, String> {
let release_id_value = release_id.to_string();
github_api_url(&[
"repos",
owner,
repo,
"releases",
&release_id_value,
"assets",
])
}
pub(crate) fn github_release_asset_delete_endpoint(
owner: &str,
repo: &str,
asset_id: u64,
) -> Result<reqwest::Url, String> {
let asset_id_value = asset_id.to_string();
github_api_url(&["repos", owner, repo, "releases", "assets", &asset_id_value])
}
pub(crate) fn github_release_asset_upload_endpoint(
owner: &str,
repo: &str,
release_id: u64,
asset_name: &str,
) -> Result<reqwest::Url, String> {
let release_id_value = release_id.to_string();
let mut url = github_upload_url(&[
"repos",
owner,
repo,
"releases",
&release_id_value,
"assets",
])?;
url.query_pairs_mut().append_pair("name", asset_name);
Ok(url)
}
fn github_api_url(segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url: reqwest::Url =
reqwest::Url::parse(GITHUB_API_BASE_URL).map_err(|e| e.to_string())?;
let mut path_segments = url
.path_segments_mut()
.map_err(|_| "invalid GitHub API base URL".to_string())?;
for segment in segments {
path_segments.push(segment);
}
drop(path_segments);
Ok(url)
}
fn github_upload_url(segments: &[&str]) -> Result<reqwest::Url, String> {
let mut url: reqwest::Url =
reqwest::Url::parse(GITHUB_UPLOADS_BASE_URL).map_err(|e| e.to_string())?;
let mut path_segments = url
.path_segments_mut()
.map_err(|_| "invalid GitHub uploads base URL".to_string())?;
for segment in segments {
path_segments.push(segment);
}
drop(path_segments);
Ok(url)
}
fn github_release_headers(
request: reqwest::RequestBuilder,
token: &str,
) -> reqwest::RequestBuilder {
request
.header(reqwest::header::ACCEPT, GITHUB_RELEASE_ACCEPT_HEADER)
.header(reqwest::header::USER_AGENT, GITHUB_RELEASE_USER_AGENT)
.bearer_auth(token)
}
fn github_release_asset_upload_request(
client: &reqwest::Client,
endpoint: reqwest::Url,
token: &str,
content_type: &str,
asset_bytes: &[u8],
) -> reqwest::RequestBuilder {
let request = client
.post(endpoint.clone())
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::USER_AGENT, GITHUB_RELEASE_USER_AGENT);
let request = if github_release_upload_requires_auth(&endpoint) {
request
.header(reqwest::header::ACCEPT, GITHUB_RELEASE_ACCEPT_HEADER)
.bearer_auth(token)
} else {
request
};
request.body(asset_bytes.to_vec())
}
fn github_release_html_url(owner: &str, repo: &str, tag_name: &str) -> String {
format!(
"https://github.com/{}/{}/releases/tag/{}",
owner, repo, tag_name
)
}
fn github_release_asset_upload_redirect_target(
status: reqwest::StatusCode,
headers: &reqwest::header::HeaderMap,
current_url: &reqwest::Url,
) -> Result<Option<reqwest::Url>, String> {
if !matches!(
status,
reqwest::StatusCode::MOVED_PERMANENTLY
| reqwest::StatusCode::FOUND
| reqwest::StatusCode::TEMPORARY_REDIRECT
| reqwest::StatusCode::PERMANENT_REDIRECT
) {
return Ok(None);
}
let location = headers
.get(reqwest::header::LOCATION)
.ok_or_else(|| {
format!(
"GitHub release asset upload returned redirect status {} without a Location header",
status
)
})?
.to_str()
.map_err(|e| {
format!(
"Invalid GitHub release asset redirect Location header: {}",
e
)
})?;
let redirect_target = current_url.join(location).map_err(|e| {
format!(
"Invalid GitHub release asset redirect target {}: {}",
location, e
)
})?;
Ok(Some(redirect_target))
}
fn github_release_upload_requires_auth(url: &reqwest::Url) -> bool {
matches!(
url.host_str(),
Some("api.github.com") | Some("uploads.github.com")
)
}
async fn find_github_release_asset_id(
input: &GithubReleaseInput,
release_id: u64,
asset_name: &str,
) -> Result<Option<u64>, String> {
let endpoint = github_release_assets_endpoint(&input.owner, &input.repo, release_id)
.map_err(|e| format!("Failed to build GitHub release asset list URL: {}", e))?;
let client = reqwest::Client::new();
let request = client.get(endpoint);
let response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| format!("Failed to list GitHub release assets: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset lookup failed with status {}: {}",
status,
github_api_error_message(&body)
));
}
let assets: Vec<GithubReleaseAssetResponse> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub release asset list response: {}", e))?;
Ok(assets
.into_iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.id))
}
async fn delete_github_release_asset(
input: &GithubReleaseInput,
asset_id: u64,
asset_name: &str,
) -> Result<(), String> {
let endpoint = github_release_asset_delete_endpoint(&input.owner, &input.repo, asset_id)
.map_err(|e| format!("Failed to build GitHub release asset delete URL: {}", e))?;
let client = reqwest::Client::new();
let request = client.delete(endpoint);
let response = github_release_headers(request, &input.token)
.send()
.await
.map_err(|e| {
format!(
"Failed to delete existing GitHub release asset {}: {}",
asset_name, e
)
})?;
if response.status() != reqwest::StatusCode::NO_CONTENT {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub release asset delete failed for {} with status {}: {}",
asset_name,
status,
github_api_error_message(&body)
));
}
Ok(())
}
fn github_release_asset_content_type(asset_path: &Path) -> &'static str {
match asset_path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::{github_release_asset_upload_redirect_target, github_release_upload_requires_auth};
#[test]
fn resolves_absolute_github_release_upload_redirects() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::LOCATION,
reqwest::header::HeaderValue::from_static(
"https://github-releases.githubusercontent.com/upload/asset?sig=abc",
),
);
let current_url = reqwest::Url::parse(
"https://uploads.github.com/repos/acme/demo/releases/42/assets?name=openapi.yaml",
)
.expect("valid current url");
let redirect_target = github_release_asset_upload_redirect_target(
reqwest::StatusCode::TEMPORARY_REDIRECT,
&headers,
¤t_url,
)
.expect("redirect should parse")
.expect("redirect target should exist");
assert_eq!(
redirect_target.as_str(),
"https://github-releases.githubusercontent.com/upload/asset?sig=abc"
);
}
#[test]
fn resolves_relative_github_release_upload_redirects() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::LOCATION,
reqwest::header::HeaderValue::from_static(
"/repos/acme/demo/releases/42/assets?name=openapi.yaml",
),
);
let current_url = reqwest::Url::parse(
"https://uploads.github.com/repos/acme/demo/releases/42/assets?name=openapi.yaml",
)
.expect("valid current url");
let redirect_target = github_release_asset_upload_redirect_target(
reqwest::StatusCode::MOVED_PERMANENTLY,
&headers,
¤t_url,
)
.expect("redirect should parse")
.expect("redirect target should exist");
assert_eq!(
redirect_target.as_str(),
"https://uploads.github.com/repos/acme/demo/releases/42/assets?name=openapi.yaml"
);
}
#[test]
fn redirect_requires_location_header() {
let headers = reqwest::header::HeaderMap::new();
let current_url = reqwest::Url::parse(
"https://uploads.github.com/repos/acme/demo/releases/42/assets?name=openapi.yaml",
)
.expect("valid current url");
let error = github_release_asset_upload_redirect_target(
reqwest::StatusCode::PERMANENT_REDIRECT,
&headers,
¤t_url,
)
.expect_err("missing location should fail");
assert!(error.contains("Location header"));
}
#[test]
fn upload_auth_is_limited_to_github_hosts() {
let github_upload =
reqwest::Url::parse("https://uploads.github.com/repos/acme/demo/releases/42/assets")
.expect("valid github uploads url");
let github_api =
reqwest::Url::parse("https://api.github.com/repos/acme/demo/releases/42/assets")
.expect("valid github api url");
let external =
reqwest::Url::parse("https://github-releases.githubusercontent.com/upload/asset")
.expect("valid redirect url");
assert!(github_release_upload_requires_auth(&github_upload));
assert!(github_release_upload_requires_auth(&github_api));
assert!(!github_release_upload_requires_auth(&external));
}
}