use std::path::Path;
use anodizer_core::redact::redact_bearer_tokens;
use anodizer_core::retry::{RetryPolicy, SuccessClass, retry_http_async};
use anodizer_core::url::percent_encode_path_segment as encode_segment;
use anyhow::{Context as _, Result};
use reqwest::Client;
use crate::release_body::compose_body_for_mode;
#[derive(Clone, Copy)]
pub(crate) struct GiteaCtx<'a> {
pub client: &'a Client,
pub api_url: &'a str,
pub owner: &'a str,
pub repo: &'a str,
pub policy: &'a RetryPolicy,
}
#[derive(Clone, Copy)]
pub(crate) struct GiteaReleaseSpec<'a> {
pub tag: &'a str,
pub commit: &'a str,
pub name: &'a str,
pub body: &'a str,
pub draft: bool,
pub prerelease: bool,
pub release_mode: &'a str,
}
#[derive(Clone, Copy)]
pub(crate) struct GiteaAssetSpec<'a> {
pub file_path: &'a Path,
pub file_name: &'a str,
}
pub(crate) fn gitea_release_url(download_url: &str, owner: &str, repo: &str, tag: &str) -> String {
let base = download_url.trim_end_matches('/');
format!(
"{}/{}/{}/releases/tag/{}",
base,
encode_segment(owner),
encode_segment(repo),
encode_segment(tag)
)
}
pub(crate) fn build_gitea_client(token: &str, skip_tls_verify: bool) -> Result<Client> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("token {}", token))
.context("gitea: invalid token value for Authorization header")?,
);
let builder = Client::builder()
.default_headers(headers)
.danger_accept_invalid_certs(skip_tls_verify)
.timeout(std::time::Duration::from_secs(300));
builder.build().context("gitea: build HTTP client")
}
pub(crate) async fn gitea_create_release(
ctx: &GiteaCtx<'_>,
spec: &GiteaReleaseSpec<'_>,
) -> Result<u64> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let GiteaReleaseSpec {
tag,
commit,
name,
body,
draft,
prerelease,
release_mode,
} = *spec;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let existing = find_release_by_tag(client, api, &enc_owner, &enc_repo, tag, policy).await?;
if let Some((release_id, existing_body)) = existing {
let final_body = compose_body_for_mode(release_mode, existing_body.as_deref(), body);
let update_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}",
api, enc_owner, enc_repo, release_id
);
let payload = serde_json::json!({
"tag_name": tag,
"target_commitish": commit,
"name": name,
"body": final_body,
"draft": draft,
"prerelease": prerelease,
});
retry_http_async(
"gitea: PATCH update release",
policy,
SuccessClass::Strict,
|_| client.patch(&update_url).json(&payload).send(),
|status, body| {
format!(
"gitea: update release failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
Ok(release_id)
} else {
let create_url = format!("{}/api/v1/repos/{}/{}/releases", api, enc_owner, enc_repo);
let payload = serde_json::json!({
"tag_name": tag,
"target_commitish": commit,
"name": name,
"body": body,
"draft": draft,
"prerelease": prerelease,
});
let resp = retry_http_async(
"gitea: POST create release",
policy,
SuccessClass::Strict,
|_| client.post(&create_url).json(&payload).send(),
|status, body| {
format!(
"gitea: create release failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let json: serde_json::Value = resp
.json()
.await
.context("gitea: parse create release response JSON")?;
let release_id = json["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: create release response missing 'id' field"))?;
Ok(release_id)
}
}
async fn find_release_by_tag(
client: &Client,
api: &str,
enc_owner: &str,
enc_repo: &str,
tag: &str,
policy: &RetryPolicy,
) -> Result<Option<(u64, Option<String>)>> {
const MAX_PAGES: u32 = 10;
const PAGE_SIZE: u32 = 50;
for page in 1..=MAX_PAGES {
let url = format!(
"{}/api/v1/repos/{}/{}/releases?page={}&limit={}",
api, enc_owner, enc_repo, page, PAGE_SIZE
);
let resp = retry_http_async(
&format!("gitea: GET releases page {page}"),
policy,
SuccessClass::Strict,
|_| client.get(&url).send(),
|status, body| {
format!(
"gitea: list releases failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let releases: Vec<serde_json::Value> = resp
.json()
.await
.context("gitea: parse releases list JSON")?;
for release in &releases {
if release["tag_name"].as_str() == Some(tag) {
let id = release["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: release missing 'id' field"))?;
let body = release["body"].as_str().map(|s| s.to_string());
return Ok(Some((id, body)));
}
}
if releases.len() < PAGE_SIZE as usize {
break;
}
}
Ok(None)
}
pub(crate) async fn gitea_upload_asset(
ctx: &GiteaCtx<'_>,
release_id: u64,
asset: &GiteaAssetSpec<'_>,
) -> Result<()> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let GiteaAssetSpec {
file_path,
file_name,
} = *asset;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let enc_filename = encode_segment(file_name);
let upload_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets?name={}",
api, enc_owner, enc_repo, release_id, enc_filename
);
let data = tokio::fs::read(file_path)
.await
.with_context(|| format!("gitea: read file {}", file_path.display()))?;
retry_http_async(
"gitea: POST upload asset",
policy,
SuccessClass::Strict,
|_| {
let file_part = match reqwest::multipart::Part::bytes(data.clone())
.file_name(file_name.to_string())
.mime_str("application/octet-stream")
{
Ok(p) => p,
Err(_) => unreachable!("application/octet-stream is a valid MIME type"),
};
let form = reqwest::multipart::Form::new().part("attachment", file_part);
client.post(&upload_url).multipart(form).send()
},
|status, body| {
format!(
"gitea: upload asset '{}' to release {} failed (HTTP {status}): {}",
file_name,
release_id,
redact_bearer_tokens(body)
)
},
)
.await?;
Ok(())
}
pub(crate) async fn gitea_delete_asset_by_name(
ctx: &GiteaCtx<'_>,
release_id: u64,
file_name: &str,
) -> Result<bool> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let list_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets",
api, enc_owner, enc_repo, release_id
);
let resp = retry_http_async(
"gitea: GET release assets",
policy,
SuccessClass::Strict,
|_| client.get(&list_url).send(),
|status, body| {
format!(
"gitea: list release assets failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let assets: Vec<serde_json::Value> = resp
.json()
.await
.context("gitea: parse release assets JSON")?;
for asset in &assets {
if asset["name"].as_str() == Some(file_name) {
let asset_id = asset["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: asset missing 'id' field"))?;
let delete_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets/{}",
api, enc_owner, enc_repo, release_id, asset_id
);
retry_http_async(
"gitea: DELETE asset",
policy,
SuccessClass::Strict,
|_| client.delete(&delete_url).send(),
|status, body| {
format!(
"gitea: delete asset '{}' (id={}) from release {} failed (HTTP {status}): {}",
file_name,
asset_id,
release_id,
redact_bearer_tokens(body)
)
},
)
.await?;
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_url_basic() {
let url = gitea_release_url("https://gitea.example.com", "myorg", "myapp", "v1.0.0");
assert_eq!(
url,
"https://gitea.example.com/myorg/myapp/releases/tag/v1.0.0"
);
}
#[test]
fn release_url_trailing_slash_stripped() {
let url = gitea_release_url("https://gitea.example.com/", "org", "repo", "v2.0.0");
assert_eq!(
url,
"https://gitea.example.com/org/repo/releases/tag/v2.0.0"
);
}
#[test]
fn release_url_special_chars_in_tag() {
let url = gitea_release_url(
"https://gitea.example.com",
"myorg",
"myapp",
"v1.0.0+build.1",
);
assert_eq!(
url,
"https://gitea.example.com/myorg/myapp/releases/tag/v1.0.0%2Bbuild.1"
);
}
#[test]
fn release_url_special_chars_in_owner_and_repo() {
let url = gitea_release_url("https://gitea.example.com", "my org", "my repo", "v1.0.0");
assert!(url.contains("my%20org"), "owner should be percent-encoded");
assert!(url.contains("my%20repo"), "repo should be percent-encoded");
}
#[test]
fn encode_segment_simple() {
assert_eq!(encode_segment("v1.0.0"), "v1.0.0");
}
#[test]
fn encode_segment_with_plus() {
assert_eq!(encode_segment("v1.0.0+build.1"), "v1.0.0%2Bbuild.1");
}
#[test]
fn encode_segment_with_special_chars() {
assert_eq!(encode_segment("v1 beta#2?rc"), "v1%20beta%232%3Frc");
}
#[test]
fn encode_segment_preserves_dots_dashes_underscores() {
assert_eq!(encode_segment("my-project_v2.0"), "my-project_v2.0");
}
#[test]
fn build_client_normal() {
let client = build_gitea_client("giteatok-xxxx", false);
assert!(client.is_ok());
}
#[test]
fn build_client_skip_tls() {
let client = build_gitea_client("giteatok-xxxx", true);
assert!(client.is_ok());
}
#[test]
fn gitea_auth_header_format() {
let token = "my-gitea-token";
let expected_header = format!("token {}", token);
let client = build_gitea_client(token, false).unwrap();
let header_value = reqwest::header::HeaderValue::from_str(&expected_header).unwrap();
assert_eq!(
header_value.to_str().unwrap(),
"token my-gitea-token",
"Gitea auth header must use 'token {{value}}' format"
);
drop(client);
}
fn spawn_oneshot_http_responder(
responses: Vec<&'static str>,
) -> (
std::net::SocketAddr,
std::sync::Arc<std::sync::atomic::AtomicU32>,
) {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
let counter = std::sync::Arc::new(AtomicU32::new(0));
let counter_inner = counter.clone();
std::thread::spawn(move || {
for (i, resp) in responses.iter().enumerate() {
let (mut stream, _) = match listener.accept() {
Ok(pair) => pair,
Err(_) => return,
};
counter_inner.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 8192];
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let _ = stream.read(&mut buf);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Both);
if i == responses.len() - 1 {
break;
}
}
});
(addr, counter)
}
#[tokio::test]
async fn gitea_create_release_retries_5xx_on_list_releases() {
use std::sync::atomic::Ordering;
use std::time::Duration;
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: 9\r\n\r\n{\"id\":42}",
]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc123",
name: "Release v1.0.0",
body: "release body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let result = gitea_create_release(&ctx, &spec).await;
match result {
Ok(id) => assert_eq!(id, 42, "release id should be parsed from create response"),
Err(e) => panic!("expected success after 5xx retry, got: {e:#}"),
}
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 3 connections (503-retry GET, 200 GET, 201 POST)"
);
}
#[tokio::test]
async fn gitea_create_release_redacts_bearer_in_error_body() {
use std::time::Duration;
let leaky = r#"{"message":"401 Unauthorized: Authorization: Bearer ghp_FAKETOKEN1234567890abcdefg"}"#;
let body_len = leaky.len();
let resp: &'static str = Box::leak(
format!(
"HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\n\r\n{leaky}"
)
.into_boxed_str(),
);
let (addr, _calls) = spawn_oneshot_http_responder(vec![resp]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc123",
name: "Release v1.0.0",
body: "release body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("401 must fast-fail");
let chain = format!("{err:#}");
assert!(
!chain.contains("ghp_FAKETOKEN1234567890abcdefg"),
"bearer token leaked into error chain: {chain}"
);
assert!(
chain.contains("<redacted>"),
"expected `<redacted>` marker in error chain: {chain}"
);
}
}