use crate::error::{Error, Result};
use crate::logging::{debug, warn};
use crate::upgrade::signature;
use fs2::FileExt;
use saorsa_pqc::api::sig::MlDsaPublicKey;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
const MAX_META_BYTES: u64 = 4 * 1024;
#[derive(Clone)]
pub struct BinaryCache {
cache_dir: PathBuf,
verify_key: Option<MlDsaPublicKey>,
}
#[derive(Serialize, Deserialize)]
struct CachedArchiveMeta {
version: String,
archive_sha256: String,
cached_at_epoch_secs: u64,
}
impl BinaryCache {
#[must_use]
pub fn new(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
verify_key: None,
}
}
#[cfg(test)]
#[must_use]
pub fn new_with_verify_key(cache_dir: PathBuf, verify_key: MlDsaPublicKey) -> Self {
Self {
cache_dir,
verify_key: Some(verify_key),
}
}
#[must_use]
pub fn cached_archive_path(&self, version: &str) -> PathBuf {
self.cache_dir.join(format!("ant-node-{version}.archive"))
}
#[must_use]
fn cached_signature_path(&self, version: &str) -> PathBuf {
self.cache_dir.join(format!("ant-node-{version}.sig"))
}
fn verify_archive(&self, archive: &Path, sig: &Path) -> Result<()> {
self.verify_key.as_ref().map_or_else(
|| signature::verify_from_file(archive, sig),
|key| signature::verify_from_file_with_key(archive, sig, key),
)
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn get_verified_archive(&self, version: &str, private_dir: &Path) -> Option<PathBuf> {
let cached_archive = self.cached_archive_path(version);
let cached_sig = self.cached_signature_path(version);
let meta_path = self.meta_path(version);
let meta_data = {
let (mut meta_file, meta_len) = match open_regular_capped(&meta_path, MAX_META_BYTES) {
Ok(pair) => pair,
Err(e) => {
debug!("Rejecting cache metadata for {version}: {e}");
return None;
}
};
let cap = usize::try_from(meta_len).unwrap_or(usize::MAX);
let mut buf = String::with_capacity(cap);
if let Err(e) = meta_file.read_to_string(&mut buf) {
debug!("Failed to read cache metadata for {version}: {e}");
return None;
}
buf
};
let meta: CachedArchiveMeta = serde_json::from_str(&meta_data).ok()?;
if meta.version != version {
debug!("Binary cache version mismatch in metadata");
return None;
}
let (mut archive_file, archive_len) = match open_regular_capped(
&cached_archive,
crate::upgrade::apply::MAX_ARCHIVE_SIZE_BYTES as u64,
) {
Ok(pair) => pair,
Err(e) => {
warn!("Rejecting cached archive for {version}: {e}");
return None;
}
};
let (mut sig_file, sig_len) =
match open_regular_capped(&cached_sig, signature::SIGNATURE_SIZE as u64) {
Ok(pair) => pair,
Err(e) => {
warn!("Rejecting cached signature for {version}: {e}");
return None;
}
};
if sig_len != signature::SIGNATURE_SIZE as u64 {
warn!(
"Cached signature for {version} has wrong size ({sig_len} bytes, \
expected {})",
signature::SIGNATURE_SIZE
);
return None;
}
let private_archive = private_dir.join(format!("cached-{version}.archive"));
let private_sig = private_dir.join(format!("cached-{version}.sig"));
let cleanup = |reason: &str| {
debug!("Cleaning staged cache copy for {version}: {reason}");
let _ = fs::remove_file(&private_archive);
let _ = fs::remove_file(&private_sig);
};
if let Err(e) = (|| -> io::Result<()> {
let mut dest = File::create(&private_archive)?;
io::copy(&mut (&mut archive_file).take(archive_len), &mut dest)?;
Ok(())
})() {
debug!("Could not stage cached archive for {version}: {e}");
cleanup("archive copy failed");
return None;
}
if let Err(e) = (|| -> io::Result<()> {
let mut dest = File::create(&private_sig)?;
io::copy(&mut (&mut sig_file).take(sig_len), &mut dest)?;
Ok(())
})() {
debug!("Could not stage cached signature for {version}: {e}");
cleanup("signature copy failed");
return None;
}
let actual_hash = match sha256_file(&private_archive) {
Ok(h) => h,
Err(e) => {
cleanup(&format!("sha256 read failed: {e}"));
return None;
}
};
if actual_hash != meta.archive_sha256 {
warn!(
"Binary cache SHA-256 mismatch for version {version} \
(expected {}, got {actual_hash}) — ignoring cache entry",
meta.archive_sha256
);
cleanup("sha256 mismatch");
return None;
}
if let Err(e) = self.verify_archive(&private_archive, &private_sig) {
warn!(
"Cached archive for version {version} FAILED ML-DSA signature \
re-verification ({e}); discarding cache entry (possible \
on-disk tampering). A fresh verified download will run."
);
cleanup("signature re-verification failed");
return None;
}
debug!("Cached archive for version {version} passed ML-DSA re-verification");
Some(private_archive)
}
pub fn store_archive(
&self,
version: &str,
archive_path: &Path,
signature_path: &Path,
) -> Result<()> {
let archive_meta = fs::symlink_metadata(archive_path)?;
if !archive_meta.file_type().is_file() {
return Err(Error::Upgrade(format!(
"Refusing to cache archive for {version}: source is not a \
regular file (symlink/special)"
)));
}
let archive_len = archive_meta.len();
if archive_len > crate::upgrade::apply::MAX_ARCHIVE_SIZE_BYTES as u64 {
return Err(Error::Upgrade(format!(
"Refusing to cache archive for {version}: size {archive_len} bytes \
exceeds MAX_ARCHIVE_SIZE_BYTES"
)));
}
let sig_meta = fs::symlink_metadata(signature_path)?;
if !sig_meta.file_type().is_file() {
return Err(Error::Upgrade(format!(
"Refusing to cache archive for {version}: signature is not a \
regular file (symlink/special)"
)));
}
let sig_len = sig_meta.len();
if sig_len != signature::SIGNATURE_SIZE as u64 {
return Err(Error::Upgrade(format!(
"Refusing to cache archive for {version}: signature size {sig_len} \
bytes, expected {}",
signature::SIGNATURE_SIZE
)));
}
self.verify_archive(archive_path, signature_path)
.map_err(|e| {
Error::Upgrade(format!(
"Refusing to cache archive for {version}: signature does not verify ({e})"
))
})?;
let archive_hash = sha256_file(archive_path)?;
let dest_archive = self.cached_archive_path(version);
let dest_sig = self.cached_signature_path(version);
let meta_path = self.meta_path(version);
Self::atomic_copy(
archive_path,
&dest_archive,
&self
.cache_dir
.join(format!(".ant-node-{version}.archive.tmp")),
)?;
Self::atomic_copy(
signature_path,
&dest_sig,
&self.cache_dir.join(format!(".ant-node-{version}.sig.tmp")),
)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| Error::Upgrade(format!("System clock error: {e}")))?
.as_secs();
let meta = CachedArchiveMeta {
version: version.to_string(),
archive_sha256: archive_hash,
cached_at_epoch_secs: now,
};
let meta_json = serde_json::to_string(&meta).map_err(|e| {
Error::Upgrade(format!("Failed to serialize cached archive metadata: {e}"))
})?;
let tmp_meta = self.cache_dir.join(format!(".ant-node-{version}.meta.tmp"));
let mut f = File::create(&tmp_meta)?;
f.write_all(meta_json.as_bytes())?;
f.sync_all()?;
drop(f);
let _ = fs::remove_file(&meta_path);
fs::rename(&tmp_meta, &meta_path)?;
debug!(
"Cached verified archive for version {version} at {}",
dest_archive.display()
);
Ok(())
}
pub fn acquire_download_lock(&self) -> Result<DownloadLockGuard> {
let lock_path = self.cache_dir.join("download.lock");
let lock = File::create(&lock_path)
.map_err(|e| Error::Upgrade(format!("Failed to create download lock: {e}")))?;
lock.lock_exclusive()
.map_err(|e| Error::Upgrade(format!("Failed to acquire download lock: {e}")))?;
Ok(DownloadLockGuard { _file: lock })
}
fn atomic_copy(src: &Path, dest: &Path, tmp: &Path) -> Result<()> {
fs::copy(src, tmp)?;
let _ = fs::remove_file(dest);
fs::rename(tmp, dest)?;
Ok(())
}
fn meta_path(&self, version: &str) -> PathBuf {
self.cache_dir.join(format!("ant-node-{version}.meta.json"))
}
}
pub struct DownloadLockGuard {
_file: File,
}
fn open_regular_capped(path: &Path, max_len: u64) -> io::Result<(File, u64)> {
let pre_meta = fs::metadata(path)?;
if !pre_meta.file_type().is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a regular file (FIFO/device/socket/dir)",
));
}
let file = {
let mut opts = OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NONBLOCK);
}
opts.open(path)?
};
let meta = file.metadata()?;
if !meta.file_type().is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a regular file (FIFO/device/socket/dir)",
));
}
let len = meta.len();
if len > max_len {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("file exceeds size cap ({len} > {max_len})"),
));
}
Ok((file, len))
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file
.read(&mut buf)
.map_err(|e| Error::Upgrade(format!("Failed to read file for hashing: {e}")))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey};
use std::sync::OnceLock;
use tempfile::TempDir;
fn test_keypair() -> &'static (MlDsaPublicKey, MlDsaSecretKey) {
static KP: OnceLock<(MlDsaPublicKey, MlDsaSecretKey)> = OnceLock::new();
KP.get_or_init(|| ml_dsa_65().generate_keypair().unwrap())
}
fn cache_with_test_key(dir: &Path) -> BinaryCache {
BinaryCache::new_with_verify_key(dir.to_path_buf(), test_keypair().0.clone())
}
fn priv_dir() -> TempDir {
TempDir::new().unwrap()
}
fn make_signed_archive(dir: &Path, contents: &[u8]) -> (PathBuf, PathBuf) {
let archive = dir.join("src-archive");
fs::write(&archive, contents).unwrap();
let sig = ml_dsa_65()
.sign_with_context(&test_keypair().1, contents, signature::SIGNING_CONTEXT)
.unwrap();
let sig_path = dir.join("src-archive.sig");
fs::write(&sig_path, sig.to_bytes()).unwrap();
(archive, sig_path)
}
#[test]
fn test_miss_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
assert!(cache.get_verified_archive("1.0.0", pd.path()).is_none());
}
#[test]
fn test_store_and_get_verified_archive() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"signed archive bytes");
cache.store_archive("1.2.3", &archive, &sig).unwrap();
let got = cache
.get_verified_archive("1.2.3", pd.path())
.expect("cache hit");
assert_eq!(fs::read(&got).unwrap(), b"signed archive bytes");
assert!(
got.starts_with(pd.path()),
"returned archive must be the caller-private copy, got {got:?}"
);
assert_ne!(got, cache.cached_archive_path("1.2.3"));
}
#[test]
fn test_store_rejects_unsigned_archive() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let archive = tmp.path().join("a");
fs::write(&archive, b"unsigned").unwrap();
let bad_sig = tmp.path().join("a.sig");
fs::write(&bad_sig, vec![0u8; signature::SIGNATURE_SIZE]).unwrap();
assert!(cache.store_archive("1.0.0", &archive, &bad_sig).is_err());
assert!(cache.get_verified_archive("1.0.0", pd.path()).is_none());
}
#[test]
fn test_tampered_cached_archive_is_rejected() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit release archive");
cache.store_archive("2.0.0", &archive, &sig).unwrap();
assert!(cache.get_verified_archive("2.0.0", pd.path()).is_some());
let cached_archive = cache.cached_archive_path("2.0.0");
fs::write(&cached_archive, b"malicious payload").unwrap();
let forged_hash = {
let mut h = Sha256::new();
h.update(b"malicious payload");
hex::encode(h.finalize())
};
let meta = CachedArchiveMeta {
version: "2.0.0".to_string(),
archive_sha256: forged_hash,
cached_at_epoch_secs: 0,
};
fs::write(
cache.meta_path("2.0.0"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
assert!(
cache.get_verified_archive("2.0.0", pd.path()).is_none(),
"tampered cache entry must NOT be trusted even with a forged \
matching SHA-256 — the signature gate runs on every hit"
);
}
#[test]
fn test_returned_archive_is_private_copy_immune_to_post_hit_swap() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"the real signed release");
cache.store_archive("3.0.0", &archive, &sig).unwrap();
let verified = cache
.get_verified_archive("3.0.0", pd.path())
.expect("cache hit");
fs::write(
cache.cached_archive_path("3.0.0"),
b"post-verify malicious swap",
)
.unwrap();
assert_eq!(
fs::read(&verified).unwrap(),
b"the real signed release",
"extraction must read the verified private bytes, not the \
attacker's post-verification swap"
);
}
#[test]
fn test_missing_signature_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"data");
cache.store_archive("1.0.0", &archive, &sig).unwrap();
fs::remove_file(cache.cached_signature_path("1.0.0")).unwrap();
assert!(cache.get_verified_archive("1.0.0", pd.path()).is_none());
}
#[test]
fn test_missing_meta_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"data");
cache.store_archive("1.0.0", &archive, &sig).unwrap();
fs::remove_file(cache.meta_path("1.0.0")).unwrap();
assert!(cache.get_verified_archive("1.0.0", pd.path()).is_none());
}
#[test]
fn test_oversize_cached_archive_is_rejected_before_copy() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("3.1.0", &archive, &sig).unwrap();
let cached_archive = cache.cached_archive_path("3.1.0");
let oversize = crate::upgrade::apply::MAX_ARCHIVE_SIZE_BYTES as u64 + 1;
{
let f = File::create(&cached_archive).unwrap();
f.set_len(oversize).unwrap();
}
assert!(cache.get_verified_archive("3.1.0", pd.path()).is_none());
let private_archive = pd.path().join("cached-3.1.0.archive");
assert!(
!private_archive.exists(),
"oversize entry must NOT be staged into private dir"
);
}
#[test]
fn test_wrong_size_signature_is_rejected_before_copy() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("3.2.0", &archive, &sig).unwrap();
fs::write(cache.cached_signature_path("3.2.0"), b"too-short").unwrap();
assert!(cache.get_verified_archive("3.2.0", pd.path()).is_none());
}
#[test]
fn test_store_archive_rejects_oversize() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let big = tmp.path().join("big.archive");
{
let f = File::create(&big).unwrap();
f.set_len(crate::upgrade::apply::MAX_ARCHIVE_SIZE_BYTES as u64 + 1)
.unwrap();
}
let any_sig = tmp.path().join("any.sig");
fs::write(&any_sig, vec![0u8; signature::SIGNATURE_SIZE]).unwrap();
assert!(cache.store_archive("9.9.9", &big, &any_sig).is_err());
}
#[cfg(unix)]
#[test]
fn test_symlink_cached_archive_is_rejected_before_copy() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("4.0.0", &archive, &sig).unwrap();
let cached_archive = cache.cached_archive_path("4.0.0");
fs::remove_file(&cached_archive).unwrap();
std::os::unix::fs::symlink("/dev/zero", &cached_archive).unwrap();
assert!(
cache.get_verified_archive("4.0.0", pd.path()).is_none(),
"a symlinked cached archive must be rejected pre-copy, \
not chased into /dev/zero"
);
assert!(!pd.path().join("cached-4.0.0.archive").exists());
}
#[test]
fn test_oversized_meta_is_rejected() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("5.0.0", &archive, &sig).unwrap();
let meta_path = cache.meta_path("5.0.0");
let huge = vec![b'a'; usize::try_from(MAX_META_BYTES).unwrap_or(usize::MAX) + 1024];
fs::write(&meta_path, &huge).unwrap();
assert!(
cache.get_verified_archive("5.0.0", pd.path()).is_none(),
"oversized metadata file must be rejected before parsing"
);
}
#[cfg(unix)]
#[test]
fn test_fifo_cached_archive_does_not_hang() {
use std::time::{Duration, Instant};
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("6.0.0", &archive, &sig).unwrap();
let cached_archive = cache.cached_archive_path("6.0.0");
fs::remove_file(&cached_archive).unwrap();
let cstr = std::ffi::CString::new(cached_archive.as_os_str().as_encoded_bytes()).unwrap();
#[allow(unsafe_code)]
let rc = unsafe { libc::mkfifo(cstr.as_ptr(), 0o600) };
assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
let start = Instant::now();
let got = cache.get_verified_archive("6.0.0", pd.path());
let elapsed = start.elapsed();
assert!(
got.is_none(),
"a FIFO planted at the cached archive path must be rejected"
);
assert!(
elapsed < Duration::from_secs(5),
"open of FIFO returned in {elapsed:?}, expected ≪ 5s — \
pre-check or O_NONBLOCK is not catching this"
);
assert!(!pd.path().join("cached-6.0.0.archive").exists());
}
#[cfg(unix)]
#[test]
fn test_meta_symlink_to_special_file_is_rejected() {
let tmp = TempDir::new().unwrap();
let cache = cache_with_test_key(tmp.path());
let pd = priv_dir();
let (archive, sig) = make_signed_archive(tmp.path(), b"legit");
cache.store_archive("5.1.0", &archive, &sig).unwrap();
let meta_path = cache.meta_path("5.1.0");
fs::remove_file(&meta_path).unwrap();
std::os::unix::fs::symlink("/dev/zero", &meta_path).unwrap();
assert!(
cache.get_verified_archive("5.1.0", pd.path()).is_none(),
"metadata symlink to a special file must be rejected"
);
}
}