use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::errors;
use crate::processors::{check_command_output, run_command_capture};
pub trait RemoteCache: Send + Sync {
fn exists(&self, key: &str) -> Result<bool>;
fn download(&self, key: &str, dest: &Path) -> Result<bool>;
fn upload(&self, key: &str, src: &Path) -> Result<()>;
fn download_bytes(&self, key: &str) -> Result<Option<Vec<u8>>>;
fn upload_bytes(&self, key: &str, data: &[u8]) -> Result<()> {
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join(format!("rsconstruct-upload-{}", uuid_simple()));
fs::write(&temp_file, data)?;
let result = self.upload(key, &temp_file);
let _ = fs::remove_file(&temp_file);
result
}
}
pub fn create_backend(url: &str) -> Result<Box<dyn RemoteCache>> {
if url.starts_with("s3://") {
Ok(Box::new(S3Backend::new(url)?))
} else if url.starts_with("http://") || url.starts_with("https://") {
Ok(Box::new(HttpBackend::new(url)?))
} else if url.starts_with("file://") {
Ok(Box::new(FileBackend::new(url)?))
} else {
anyhow::bail!(
"Unsupported remote cache URL: {}. Supported schemes: s3://, http://, https://, file://",
url
)
}
}
pub struct S3Backend {
bucket: String,
prefix: String,
}
impl S3Backend {
pub fn new(url: &str) -> Result<Self> {
let without_scheme = url
.strip_prefix("s3://")
.context("Invalid S3 URL")?;
let (bucket, prefix) = match without_scheme.find('/') {
Some(idx) => {
let (b, p) = without_scheme.split_at(idx);
(b.to_string(), p[1..].to_string()) }
None => (without_scheme.to_string(), String::new()),
};
anyhow::ensure!(!bucket.is_empty(), "Invalid S3 URL: missing bucket name in {}", url);
Ok(Self { bucket, prefix })
}
fn s3_key(&self, key: &str) -> String {
if self.prefix.is_empty() {
key.to_string()
} else {
format!("{}/{}", self.prefix.trim_end_matches('/'), key)
}
}
fn s3_uri(&self, key: &str) -> String {
format!("s3://{}/{}", self.bucket, self.s3_key(key))
}
}
impl RemoteCache for S3Backend {
fn exists(&self, key: &str) -> Result<bool> {
let mut cmd = Command::new("aws");
cmd.args(["s3", "ls", &self.s3_uri(key)]);
let output = run_command_capture(&mut cmd)?;
Ok(output.status.success())
}
fn download(&self, key: &str, dest: &Path) -> Result<bool> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("aws");
cmd.args([
"s3", "cp",
&self.s3_uri(key),
&dest.display().to_string(),
"--only-show-errors",
]);
let output = run_command_capture(&mut cmd)?;
Ok(output.status.success())
}
fn upload(&self, key: &str, src: &Path) -> Result<()> {
let mut cmd = Command::new("aws");
cmd.args([
"s3", "cp",
&src.display().to_string(),
&self.s3_uri(key),
"--only-show-errors",
]);
let output = run_command_capture(&mut cmd)?;
check_command_output(&output, "S3 upload")
}
fn download_bytes(&self, key: &str) -> Result<Option<Vec<u8>>> {
let mut cmd = Command::new("aws");
cmd.args(["s3", "cp", &self.s3_uri(key), "-"]);
let output = run_command_capture(&mut cmd)?;
if output.status.success() {
Ok(Some(output.stdout))
} else {
Ok(None)
}
}
}
pub struct HttpBackend {
base_url: String,
}
impl HttpBackend {
pub fn new(url: &str) -> Result<Self> {
Ok(Self {
base_url: url.trim_end_matches('/').to_string(),
})
}
fn full_url(&self, key: &str) -> String {
format!("{}/{}", self.base_url, key.trim_start_matches('/'))
}
}
impl RemoteCache for HttpBackend {
fn exists(&self, key: &str) -> Result<bool> {
let mut cmd = Command::new("curl");
cmd.args([
"-s", "-o", "/dev/null", "-w", "%{http_code}", "--head",
&self.full_url(key),
]);
let output = run_command_capture(&mut cmd)?;
let status_code = String::from_utf8_lossy(&output.stdout);
Ok(status_code.trim() == "200")
}
fn download(&self, key: &str, dest: &Path) -> Result<bool> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("curl");
cmd.args([
"-s", "-f",
"-o", &dest.display().to_string(),
&self.full_url(key),
]);
let output = run_command_capture(&mut cmd)?;
Ok(output.status.success())
}
fn upload(&self, key: &str, src: &Path) -> Result<()> {
let mut cmd = Command::new("curl");
cmd.args([
"-s", "-f", "-X", "PUT",
"--data-binary", &format!("@{}", src.display()),
&self.full_url(key),
]);
let output = run_command_capture(&mut cmd)?;
check_command_output(&output, "HTTP upload")
}
fn download_bytes(&self, key: &str) -> Result<Option<Vec<u8>>> {
let mut cmd = Command::new("curl");
cmd.args(["-s", "-f", &self.full_url(key)]);
let output = run_command_capture(&mut cmd)?;
if output.status.success() {
Ok(Some(output.stdout))
} else {
Ok(None)
}
}
}
pub struct FileBackend {
base_path: PathBuf,
}
impl FileBackend {
pub fn new(url: &str) -> Result<Self> {
let path = url
.strip_prefix("file://")
.context("Invalid file:// URL")?;
let base_path = PathBuf::from(path);
fs::create_dir_all(&base_path)
.with_context(|| format!("Failed to create remote cache directory: {}", base_path.display()))?;
Ok(Self { base_path })
}
fn full_path(&self, key: &str) -> PathBuf {
self.base_path.join(key)
}
}
impl RemoteCache for FileBackend {
fn exists(&self, key: &str) -> Result<bool> {
Ok(self.full_path(key).exists())
}
fn download(&self, key: &str, dest: &Path) -> Result<bool> {
let src = self.full_path(key);
if !src.exists() {
return Ok(false);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src, dest)
.with_context(|| format!("Failed to copy from remote cache: {}", src.display()))?;
Ok(true)
}
fn upload(&self, key: &str, src: &Path) -> Result<()> {
let dest = self.full_path(key);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, &dest)
.with_context(|| format!("Failed to copy to remote cache: {}", dest.display()))?;
crate::platform::set_permissions_mode(&dest, 0o444)
.with_context(|| format!("Failed to set remote cache object read-only: {}", dest.display()))?;
Ok(())
}
fn download_bytes(&self, key: &str) -> Result<Option<Vec<u8>>> {
let path = self.full_path(key);
if !path.exists() {
return Ok(None);
}
let data = fs::read(&path)
.with_context(|| format!("Failed to read from remote cache: {}", path.display()))?;
Ok(Some(data))
}
fn upload_bytes(&self, key: &str, data: &[u8]) -> Result<()> {
let path = self.full_path(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, data)
.with_context(|| format!("Failed to write to remote cache: {}", path.display()))?;
crate::platform::set_permissions_mode(&path, 0o444)
.with_context(|| format!("Failed to set remote cache entry read-only: {}", path.display()))?;
Ok(())
}
}
fn uuid_simple() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect(errors::SYSTEM_CLOCK)
.as_nanos();
let pid = std::process::id();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{:x}-{:x}-{:x}", timestamp, pid, seq)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_file_backend() {
let temp_dir = TempDir::new().expect("failed to create temp dir");
let url = format!("file://{}", temp_dir.path().display());
let backend = FileBackend::new(&url).expect("failed to create file backend");
let key = "test/data.txt";
let data = b"hello world";
backend.upload_bytes(key, data).expect("upload failed");
assert!(backend.exists(key).expect("exists check failed"));
let downloaded = backend.download_bytes(key).expect("download failed");
assert_eq!(downloaded, Some(data.to_vec()));
}
#[test]
fn test_s3_url_parsing() {
let backend = S3Backend::new("s3://my-bucket/cache/prefix").expect("failed to parse S3 URL");
assert_eq!(backend.bucket, "my-bucket");
assert_eq!(backend.prefix, "cache/prefix");
assert_eq!(backend.s3_key("objects/ab/cdef"), "cache/prefix/objects/ab/cdef");
let backend2 = S3Backend::new("s3://bucket").expect("failed to parse S3 URL");
assert_eq!(backend2.bucket, "bucket");
assert_eq!(backend2.prefix, "");
assert_eq!(backend2.s3_key("objects/ab/cdef"), "objects/ab/cdef");
}
}