use std::io::{Read, Write};
use std::path::Path;
use std::sync::LazyLock;
use crate::cache::{CacheDir, CacheEntry, KernelMetadata};
static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create tokio runtime for remote cache")
});
pub fn is_enabled() -> bool {
std::env::var("KTSTR_GHA_CACHE")
.ok()
.is_some_and(|v| v == "1")
&& std::env::var("ACTIONS_CACHE_URL")
.ok()
.is_some_and(|v| !v.is_empty())
}
const REMOTE_CACHE_NAMESPACE: &str = "ktstr-v2";
fn create_operator() -> Result<opendal::Operator, String> {
let builder = opendal::services::Ghac::default()
.root("/")
.version(REMOTE_CACHE_NAMESPACE);
opendal::Operator::new(builder)
.map_err(|e| format!("create ghac operator: {e}"))
.map(|b| b.finish())
}
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
pub const MAX_DECOMPRESSED_REMOTE_CACHE_BYTES: u64 = 1024 * 1024 * 1024;
fn pack_entry(entry_dir: &Path, metadata: &KernelMetadata) -> Result<Vec<u8>, String> {
let mut archive = tar::Builder::new(Vec::new());
let mut meta_sanitized = metadata.clone();
if let crate::cache::KernelSource::Local {
source_tree_path, ..
} = &mut meta_sanitized.source
{
*source_tree_path = None;
}
let meta_json = serde_json::to_string_pretty(&meta_sanitized)
.map_err(|e| format!("serialize metadata: {e}"))?;
let meta_bytes = meta_json.as_bytes();
crate::tar_util::pack_tar_entry(
&mut archive,
"metadata.json",
0o644,
meta_bytes.len() as u64,
meta_bytes,
)
.map_err(|e| format!("tar append metadata: {e}"))?;
let image_path = entry_dir.join(&metadata.image_name);
let mut image_file = std::fs::File::open(&image_path)
.map_err(|e| format!("open image {}: {e}", image_path.display()))?;
let image_size = image_file
.metadata()
.map_err(|e| format!("image metadata: {e}"))?
.len();
crate::tar_util::pack_tar_entry(
&mut archive,
&metadata.image_name,
0o644,
image_size,
&mut image_file,
)
.map_err(|e| format!("tar append image: {e}"))?;
let vmlinux_path = entry_dir.join("vmlinux");
if let Ok(mut vmlinux_file) = std::fs::File::open(&vmlinux_path) {
let vmlinux_size = vmlinux_file
.metadata()
.map_err(|e| format!("vmlinux metadata: {e}"))?
.len();
crate::tar_util::pack_tar_entry(
&mut archive,
"vmlinux",
0o644,
vmlinux_size,
&mut vmlinux_file,
)
.map_err(|e| format!("tar append vmlinux: {e}"))?;
}
let tar_bytes = archive
.into_inner()
.map_err(|e| format!("finalize tar: {e}"))?;
zstd::encode_all(tar_bytes.as_slice(), 3).map_err(|e| format!("zstd compress: {e}"))
}
fn decompress_payload(data: &[u8]) -> Result<Vec<u8>, String> {
if data.len() < 4 || data[..4] != ZSTD_MAGIC {
return Err("remote cache entry missing zstd magic".to_string());
}
decompress_capped(data, MAX_DECOMPRESSED_REMOTE_CACHE_BYTES)
.map_err(|e| format!("zstd decompress: {e}"))
}
fn decompress_capped(bytes: &[u8], max_decompressed: u64) -> Result<Vec<u8>, String> {
let decoder =
zstd::stream::read::Decoder::new(bytes).map_err(|e| format!("zstd decoder init: {e}"))?;
let mut out = Vec::new();
decoder
.take(max_decompressed.saturating_add(1))
.read_to_end(&mut out)
.map_err(|e| format!("zstd decompress read: {e}"))?;
if out.len() as u64 > max_decompressed {
return Err(format!(
"zstd-decompressed payload exceeds the {max_decompressed}-byte cap (decompression-bomb guard)",
));
}
Ok(out)
}
fn unpack_and_store(cache: &CacheDir, cache_key: &str, data: &[u8]) -> Result<CacheEntry, String> {
let tar_bytes = decompress_payload(data)?;
let mut archive = tar::Archive::new(tar_bytes.as_slice());
let entries = archive
.entries()
.map_err(|e| format!("read tar entries: {e}"))?;
let mut metadata: Option<KernelMetadata> = None;
let mut image_data: Option<(String, Vec<u8>)> = None;
let mut vmlinux_data: Option<Vec<u8>> = None;
for entry_result in entries {
let mut entry = entry_result.map_err(|e| format!("tar entry: {e}"))?;
let path = entry
.path()
.map_err(|e| format!("tar entry path: {e}"))?
.to_string_lossy()
.into_owned();
if path == "metadata.json" {
let mut content = String::new();
entry
.read_to_string(&mut content)
.map_err(|e| format!("read metadata from tar: {e}"))?;
metadata = Some(
serde_json::from_str(&content)
.map_err(|e| format!("parse metadata from tar: {e}"))?,
);
} else if path == "vmlinux" {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.map_err(|e| format!("read vmlinux from tar: {e}"))?;
vmlinux_data = Some(data);
} else {
let mut data = Vec::new();
entry
.read_to_end(&mut data)
.map_err(|e| format!("read image from tar: {e}"))?;
image_data = Some((path, data));
}
}
let meta = metadata.ok_or_else(|| "tar archive missing metadata.json".to_string())?;
let (_, img_bytes) =
image_data.ok_or_else(|| "tar archive missing kernel image".to_string())?;
let tmp_dir = tempfile::TempDir::new().map_err(|e| format!("create temp dir: {e}"))?;
let tmp_image = tmp_dir.path().join(&meta.image_name);
let mut f = std::fs::File::create(&tmp_image).map_err(|e| format!("create temp image: {e}"))?;
f.write_all(&img_bytes)
.map_err(|e| format!("write temp image: {e}"))?;
drop(f);
let tmp_vmlinux_path;
let vmlinux_ref = if let Some(ref vml_bytes) = vmlinux_data {
tmp_vmlinux_path = tmp_dir.path().join("vmlinux");
let mut vf = std::fs::File::create(&tmp_vmlinux_path)
.map_err(|e| format!("create temp vmlinux: {e}"))?;
vf.write_all(vml_bytes)
.map_err(|e| format!("write temp vmlinux: {e}"))?;
drop(vf);
Some(tmp_vmlinux_path.as_path())
} else {
None
};
let mut artifacts = crate::cache::CacheArtifacts::new(&tmp_image);
if let Some(v) = vmlinux_ref {
artifacts = artifacts.with_vmlinux(v);
}
cache
.store(cache_key, &artifacts, &meta)
.map_err(|e| format!("local cache store: {e}"))
}
pub fn remote_lookup(cache: &CacheDir, cache_key: &str, cli_label: &str) -> Option<CacheEntry> {
let op = match create_operator() {
Ok(op) => op,
Err(e) => {
eprintln!("{cli_label}: remote cache warning: {e}");
return None;
}
};
let data = match RUNTIME.block_on(op.read(cache_key)) {
Ok(buf) => buf.to_vec(),
Err(e) => {
if e.kind() == opendal::ErrorKind::NotFound {
return None;
}
eprintln!("{cli_label}: remote cache read warning: {e}");
return None;
}
};
match unpack_and_store(cache, cache_key, &data) {
Ok(entry) => {
eprintln!("{cli_label}: fetched from remote cache: {cache_key}");
Some(entry)
}
Err(e) => {
eprintln!("{cli_label}: remote cache unpack warning ({cache_key}): {e}");
None
}
}
}
pub fn remote_store(entry: &CacheEntry, cli_label: &str) {
let meta = &entry.metadata;
let op = match create_operator() {
Ok(op) => op,
Err(e) => {
eprintln!("{cli_label}: remote cache warning: {e}");
return;
}
};
let data = match pack_entry(&entry.path, meta) {
Ok(d) => d,
Err(e) => {
eprintln!("{cli_label}: remote cache pack warning: {e}");
return;
}
};
match RUNTIME.block_on(op.write(&entry.key, data)) {
Ok(_) => {
eprintln!("{cli_label}: stored to remote cache: {}", entry.key);
}
Err(e) => {
eprintln!("{cli_label}: remote cache write warning: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
fn test_metadata() -> KernelMetadata {
KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
}
fn create_fake_image(dir: &std::path::Path) -> std::path::PathBuf {
let image = dir.join("bzImage");
std::fs::write(&image, b"fake kernel image data for testing").unwrap();
image
}
#[test]
fn remote_cache_disabled_by_default() {
let _g1 = EnvVarGuard::remove("KTSTR_GHA_CACHE");
let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
assert!(!is_enabled());
}
#[test]
fn remote_cache_disabled_without_cache_url() {
let _g1 = EnvVarGuard::set("KTSTR_GHA_CACHE", "1");
let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
assert!(!is_enabled());
}
#[test]
fn remote_cache_disabled_without_gha_flag() {
let _g1 = EnvVarGuard::remove("KTSTR_GHA_CACHE");
let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
assert!(!is_enabled());
}
#[test]
fn remote_cache_disabled_with_empty_url() {
let _g1 = EnvVarGuard::set("KTSTR_GHA_CACHE", "1");
let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "");
assert!(!is_enabled());
}
#[test]
fn remote_cache_disabled_with_wrong_flag() {
let _g1 = EnvVarGuard::set("KTSTR_GHA_CACHE", "0");
let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
assert!(!is_enabled());
}
#[test]
fn remote_cache_enabled_when_both_set() {
let _g1 = EnvVarGuard::set("KTSTR_GHA_CACHE", "1");
let _g2 = EnvVarGuard::set("ACTIONS_CACHE_URL", "https://example.com");
assert!(is_enabled());
}
#[test]
fn remote_cache_pack_unpack_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = test_metadata();
let entry = cache
.store("test-key", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
assert!(!packed.is_empty());
let tmp2 = tempfile::TempDir::new().unwrap();
let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
let restored = unpack_and_store(&cache2, "test-key", &packed).unwrap();
assert_eq!(restored.key, "test-key");
let restored_meta = &restored.metadata;
assert_eq!(restored_meta.version.as_deref(), Some("6.14.2"));
assert_eq!(restored_meta.arch, "x86_64");
assert_eq!(restored_meta.image_name, "bzImage");
assert_eq!(restored_meta.source, KernelSource::Tarball);
let restored_image = restored.path.join("bzImage");
let original_content = std::fs::read(&image).unwrap();
let restored_content = std::fs::read(&restored_image).unwrap();
assert_eq!(original_content, restored_content);
}
#[test]
fn remote_cache_pack_entry_excludes_config_sidecar() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = test_metadata();
let entry = cache
.store("legacy-config", &CacheArtifacts::new(&image), &meta)
.unwrap();
std::fs::write(entry.path.join(".config"), b"CONFIG_HZ=1000\n").unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
let tar_bytes = decompress_payload(&packed).unwrap();
let mut archive = tar::Archive::new(tar_bytes.as_slice());
let paths: Vec<String> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
!paths.iter().any(|p| p == ".config"),
"pack_entry should not include .config, got {paths:?}"
);
}
#[test]
fn remote_cache_pack_produces_valid_tar() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = test_metadata();
let entry = cache
.store("valid-tar", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
let tar_bytes = decompress_payload(&packed).unwrap();
let mut archive = tar::Archive::new(tar_bytes.as_slice());
let entries: Vec<_> = archive.entries().unwrap().collect();
assert_eq!(entries.len(), 2);
}
#[test]
fn remote_cache_pack_is_zstd_compressed() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = test_metadata();
let entry = cache
.store("zstd-key", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
assert!(
packed.len() >= 4 && packed[..4] == ZSTD_MAGIC,
"packed data should start with zstd magic"
);
}
#[test]
fn remote_cache_unpack_rejects_raw_tar() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let mut archive = tar::Builder::new(Vec::new());
let meta = test_metadata();
let meta_json = serde_json::to_string_pretty(&meta).unwrap();
let meta_bytes = meta_json.as_bytes();
crate::tar_util::pack_tar_entry(
&mut archive,
"metadata.json",
0o644,
meta_bytes.len() as u64,
meta_bytes,
)
.unwrap();
let raw_tar = archive.into_inner().unwrap();
assert!(raw_tar.len() < 4 || raw_tar[..4] != ZSTD_MAGIC);
let err = unpack_and_store(&cache, "raw-tar-key", &raw_tar).unwrap_err();
assert!(
err.contains("zstd magic"),
"non-zstd payload must be rejected with a `zstd magic` \
diagnostic from the precondition check, got: {err}",
);
}
#[test]
fn remote_cache_decompress_payload_rejects_short_inputs() {
for len in 0..=3 {
let bytes = vec![0u8; len];
let err = super::decompress_payload(&bytes).unwrap_err();
assert!(
err.contains("zstd magic"),
"{len}-byte payload must be rejected by the magic-number \
precondition, got: {err}",
);
}
}
#[test]
fn remote_cache_unpack_rejects_missing_metadata() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let mut archive = tar::Builder::new(Vec::new());
let data = b"kernel image";
crate::tar_util::pack_tar_entry(
&mut archive,
"bzImage",
0o644,
data.len() as u64,
data.as_slice(),
)
.unwrap();
let raw_tar = archive.into_inner().unwrap();
let packed = zstd::encode_all(raw_tar.as_slice(), 3).unwrap();
let result = unpack_and_store(&cache, "no-meta", &packed);
assert!(result.is_err());
assert!(
result.unwrap_err().contains("missing metadata"),
"expected metadata error"
);
}
#[test]
fn remote_cache_unpack_rejects_missing_image() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let mut archive = tar::Builder::new(Vec::new());
let meta = test_metadata();
let meta_json = serde_json::to_string_pretty(&meta).unwrap();
let meta_bytes = meta_json.as_bytes();
crate::tar_util::pack_tar_entry(
&mut archive,
"metadata.json",
0o644,
meta_bytes.len() as u64,
meta_bytes,
)
.unwrap();
let raw_tar = archive.into_inner().unwrap();
let packed = zstd::encode_all(raw_tar.as_slice(), 3).unwrap();
let result = unpack_and_store(&cache, "no-image", &packed);
assert!(result.is_err());
assert!(
result.unwrap_err().contains("missing kernel image"),
"expected image error"
);
}
#[test]
fn remote_cache_remote_lookup_returns_none_when_disabled() {
let _g1 = EnvVarGuard::remove("KTSTR_GHA_CACHE");
let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
assert!(!is_enabled());
}
#[test]
fn remote_cache_remote_store_when_disabled() {
let _g1 = EnvVarGuard::remove("KTSTR_GHA_CACHE");
let _g2 = EnvVarGuard::remove("ACTIONS_CACHE_URL");
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = test_metadata();
let entry = cache
.store("test-entry", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata);
assert!(packed.is_ok());
}
#[test]
fn remote_cache_source_tree_path_sanitized_on_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = KernelMetadata::new(
KernelSource::Local {
source_tree_path: Some(std::path::PathBuf::from("/tmp/linux-src")),
git_hash: Some("deadbee".to_string()),
},
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
);
assert!(matches!(
meta.source,
KernelSource::Local {
source_tree_path: Some(_),
git_hash: Some(_),
}
));
let entry = cache
.store("stp-key", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
let tmp2 = tempfile::TempDir::new().unwrap();
let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
let restored = unpack_and_store(&cache2, "stp-key", &packed).unwrap();
let restored_meta = &restored.metadata;
assert!(
matches!(
&restored_meta.source,
KernelSource::Local {
source_tree_path: None,
git_hash: Some(h),
} if h == "deadbee"
),
"source_tree_path must be stripped during pack, git_hash must survive"
);
}
#[test]
fn remote_cache_pack_with_git_metadata() {
let tmp = tempfile::TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src = tempfile::TempDir::new().unwrap();
let image = create_fake_image(src.path());
let meta = KernelMetadata::new(
KernelSource::Git {
git_hash: Some("a1b2c3d".to_string()),
git_ref: Some("v6.15-rc3".to_string()),
},
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T12:00:00Z".to_string(),
);
let entry = cache
.store("git-key", &CacheArtifacts::new(&image), &meta)
.unwrap();
let packed = pack_entry(&entry.path, &entry.metadata).unwrap();
let tmp2 = tempfile::TempDir::new().unwrap();
let cache2 = CacheDir::with_root(tmp2.path().join("cache"));
let restored = unpack_and_store(&cache2, "git-key", &packed).unwrap();
let rmeta = &restored.metadata;
assert!(matches!(
rmeta.source,
KernelSource::Git {
git_hash: Some(ref h),
git_ref: Some(ref r),
}
if h == "a1b2c3d" && r == "v6.15-rc3"
));
}
#[test]
fn remote_cache_decompress_capped_rejects_decompression_bomb() {
let payload = vec![0u8; 8192];
let compressed = zstd::encode_all(payload.as_slice(), 3).unwrap();
let cap: u64 = 1024;
let err = super::decompress_capped(&compressed, cap).unwrap_err();
assert!(
err.contains("decompression-bomb guard"),
"expected decompression-bomb guard error, got: {err}",
);
}
#[test]
fn remote_cache_decompress_capped_accepts_payload_at_cap_boundary() {
let payload = b"hello world".to_vec();
let compressed = zstd::encode_all(payload.as_slice(), 3).unwrap();
let out = super::decompress_capped(&compressed, payload.len() as u64).unwrap();
assert_eq!(
out, payload,
"payload exactly at the cap must round-trip — \
cap is inclusive (`>` not `>=`)",
);
}
#[test]
fn remote_cache_namespace_has_version_suffix() {
let ns = super::REMOTE_CACHE_NAMESPACE;
assert!(!ns.is_empty(), "namespace must not be empty");
assert!(
ns.starts_with("ktstr-v"),
"namespace must keep `ktstr-v` prefix; got: {ns}",
);
let suffix = ns.strip_prefix("ktstr-v").unwrap();
assert!(
suffix.parse::<u32>().is_ok(),
"version suffix must be numeric; got: {suffix:?}",
);
}
use crate::test_support::test_helpers::EnvVarGuard;
}