use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GhaError {
#[error("GHA cache API not available (ACTIONS_CACHE_URL or ACTIONS_RUNTIME_TOKEN not set)")]
NotAvailable,
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: {status} {body}")]
Api { status: u16, body: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Cache not found for key: {0}")]
NotFound(String),
}
#[derive(Debug)]
pub struct GhaCache {
client: Client,
base_url: String,
token: String,
}
#[derive(Serialize)]
struct ReserveCacheRequest {
key: String,
version: String,
}
#[derive(Deserialize)]
struct ReserveCacheResponse {
#[serde(rename = "cacheId")]
cache_id: i64,
}
#[derive(Serialize)]
struct CommitCacheRequest {
size: u64,
}
#[derive(Deserialize)]
struct RestoreCacheResponse {
#[serde(rename = "archiveLocation")]
archive_location: Option<String>,
#[serde(rename = "cacheKey")]
#[allow(dead_code)]
cache_key: Option<String>,
}
const API_VERSION: &str = "application/json;api-version=6.0-preview.1";
impl GhaCache {
pub fn from_env() -> Result<Self, GhaError> {
let base_url = std::env::var("ACTIONS_CACHE_URL").map_err(|_| GhaError::NotAvailable)?;
let token = std::env::var("ACTIONS_RUNTIME_TOKEN").map_err(|_| GhaError::NotAvailable)?;
let client = Client::builder()
.user_agent("zccache")
.build()
.map_err(GhaError::Http)?;
Ok(Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
token,
})
}
pub fn is_available() -> bool {
std::env::var("ACTIONS_CACHE_URL").is_ok() && std::env::var("ACTIONS_RUNTIME_TOKEN").is_ok()
}
fn api_url(&self, path: &str) -> String {
format!("{}/_apis/artifactcache/{}", self.base_url, path)
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.token)
}
pub fn version_hash(paths: &[&str]) -> String {
let mut hasher = Sha256::new();
for p in paths {
hasher.update(p.as_bytes());
hasher.update(b"|");
}
format!("{:x}", hasher.finalize())
}
pub async fn save(&self, key: &str, version: &str, data: &[u8]) -> Result<(), GhaError> {
let reserve_url = self.api_url("caches");
let resp = self
.client
.post(&reserve_url)
.header("Authorization", self.auth_header())
.header("Accept", API_VERSION)
.json(&ReserveCacheRequest {
key: key.to_string(),
version: version.to_string(),
})
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
if status == 409 {
tracing::info!("cache already exists for key: {key}");
return Ok(());
}
return Err(GhaError::Api { status, body });
}
let reserve: ReserveCacheResponse = resp.json().await?;
let cache_id = reserve.cache_id;
let upload_url = self.api_url(&format!("caches/{cache_id}"));
let len = data.len();
let content_range = if len == 0 {
"bytes */*".to_string()
} else {
format!("bytes 0-{}/{len}", len - 1)
};
let resp = self
.client
.patch(&upload_url)
.header("Authorization", self.auth_header())
.header("Content-Type", "application/octet-stream")
.header("Content-Range", content_range)
.body(data.to_vec())
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(GhaError::Api { status, body });
}
let commit_url = self.api_url(&format!("caches/{cache_id}"));
let resp = self
.client
.post(&commit_url)
.header("Authorization", self.auth_header())
.header("Accept", API_VERSION)
.json(&CommitCacheRequest { size: len as u64 })
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(GhaError::Api { status, body });
}
tracing::info!("saved {len} bytes to GHA cache key: {key}");
Ok(())
}
pub async fn restore(&self, key: &str, version: &str) -> Result<Option<Vec<u8>>, GhaError> {
let url = self.api_url(&format!("cache?keys={key}&version={version}"));
let resp = self
.client
.get(&url)
.header("Authorization", self.auth_header())
.header("Accept", API_VERSION)
.send()
.await?;
if resp.status().as_u16() == 204 {
return Ok(None);
}
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(GhaError::Api { status, body });
}
let result: RestoreCacheResponse = resp.json().await?;
let location = match result.archive_location {
Some(loc) => loc,
None => return Ok(None),
};
let data = self.client.get(&location).send().await?.bytes().await?;
tracing::info!("restored {} bytes from GHA cache key: {key}", data.len());
Ok(Some(data.to_vec()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
_lock: MutexGuard<'static, ()>,
old_cache_url: Option<String>,
old_runtime_token: Option<String>,
}
impl EnvGuard {
fn new() -> Self {
Self {
_lock: env_lock().lock().unwrap(),
old_cache_url: std::env::var("ACTIONS_CACHE_URL").ok(),
old_runtime_token: std::env::var("ACTIONS_RUNTIME_TOKEN").ok(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_cache_url {
Some(value) => std::env::set_var("ACTIONS_CACHE_URL", value),
None => std::env::remove_var("ACTIONS_CACHE_URL"),
}
match &self.old_runtime_token {
Some(value) => std::env::set_var("ACTIONS_RUNTIME_TOKEN", value),
None => std::env::remove_var("ACTIONS_RUNTIME_TOKEN"),
}
}
}
#[test]
fn is_available_returns_false_without_env_vars() {
let _guard = EnvGuard::new();
std::env::remove_var("ACTIONS_CACHE_URL");
std::env::remove_var("ACTIONS_RUNTIME_TOKEN");
assert!(!GhaCache::is_available());
}
#[test]
fn version_hash_is_deterministic() {
let h1 = GhaCache::version_hash(&["a", "b", "c"]);
let h2 = GhaCache::version_hash(&["a", "b", "c"]);
assert_eq!(h1, h2);
assert!(!h1.is_empty());
}
#[test]
fn version_hash_differs_for_different_inputs() {
let h1 = GhaCache::version_hash(&["a", "b"]);
let h2 = GhaCache::version_hash(&["a", "c"]);
assert_ne!(h1, h2);
}
#[test]
fn from_env_returns_not_available_without_env_vars() {
let _guard = EnvGuard::new();
std::env::remove_var("ACTIONS_CACHE_URL");
std::env::remove_var("ACTIONS_RUNTIME_TOKEN");
let err = GhaCache::from_env().unwrap_err();
assert!(
matches!(err, GhaError::NotAvailable),
"expected NotAvailable, got: {err}"
);
}
#[test]
fn from_env_returns_not_available_with_partial_env() {
let _guard = EnvGuard::new();
std::env::set_var("ACTIONS_CACHE_URL", "https://example.com");
std::env::remove_var("ACTIONS_RUNTIME_TOKEN");
let err = GhaCache::from_env().unwrap_err();
assert!(matches!(err, GhaError::NotAvailable));
}
#[test]
fn from_env_succeeds_with_both_env_vars() {
let _guard = EnvGuard::new();
std::env::set_var("ACTIONS_CACHE_URL", "https://example.com/cache/");
std::env::set_var("ACTIONS_RUNTIME_TOKEN", "test-token");
let cache = GhaCache::from_env().expect("should succeed with both vars set");
assert_eq!(cache.base_url, "https://example.com/cache");
assert_eq!(cache.token, "test-token");
}
}