use std::path::Path;
use std::process::Command;
use super::{
AtomicRemoteBackend, BackendError, BackendObjectVersion, ConditionalDelete, ConditionalPut,
RemoteBackend,
};
#[derive(Debug, Clone)]
pub struct HttpBackendConfig {
pub base_url: String,
pub prefix: String,
pub auth_header: Option<String>,
pub conditional_writes: bool,
}
impl HttpBackendConfig {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
prefix: String::new(),
auth_header: None,
conditional_writes: false,
}
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
let mut p = prefix.into();
if !p.is_empty() && !p.ends_with('/') {
p.push('/');
}
self.prefix = p;
self
}
pub fn with_auth_header(mut self, value: impl Into<String>) -> Self {
self.auth_header = Some(value.into());
self
}
pub fn with_conditional_writes(mut self, enabled: bool) -> Self {
self.conditional_writes = enabled;
self
}
}
pub struct HttpBackend {
config: HttpBackendConfig,
}
impl HttpBackend {
pub fn new(config: HttpBackendConfig) -> Self {
Self { config }
}
fn url_for(&self, key: &str) -> String {
format!(
"{}/{}{}",
self.config.base_url,
self.config.prefix,
key.trim_start_matches('/')
)
}
fn curl(&self, args: &[&str]) -> Result<std::process::Output, BackendError> {
let args = args.iter().map(|arg| (*arg).to_string()).collect();
self.curl_owned(args, &[])
}
fn curl_owned(
&self,
args: Vec<String>,
extra_headers: &[(&str, &str)],
) -> Result<std::process::Output, BackendError> {
let mut cmd = Command::new("curl");
cmd.arg("-sS"); cmd.arg("-w").arg("HTTPSTATUS:%{http_code}");
for a in args {
cmd.arg(a);
}
if let Some(ref auth) = self.config.auth_header {
cmd.arg("-H").arg(format!("Authorization: {}", auth));
}
for (name, value) in extra_headers {
cmd.arg("-H").arg(format!("{name}: {value}"));
}
cmd.output()
.map_err(|e| BackendError::Transport(format!("curl not available: {e}")))
}
fn split_status(stdout: &[u8]) -> (u16, Vec<u8>) {
let s = String::from_utf8_lossy(stdout);
if let Some(idx) = s.rfind("HTTPSTATUS:") {
let body = stdout[..idx].to_vec();
let code: u16 = s[idx + "HTTPSTATUS:".len()..].trim().parse().unwrap_or(0);
(code, body)
} else {
(0, stdout.to_vec())
}
}
fn header_value(headers: &[u8], name: &str) -> Option<String> {
let needle = format!("{}:", name.to_ascii_lowercase());
String::from_utf8_lossy(headers)
.lines()
.filter_map(|line| {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
lower
.starts_with(&needle)
.then(|| trimmed[needle.len()..].trim().to_string())
})
.next_back()
.filter(|value| !value.is_empty())
}
#[inline]
fn null_device() -> &'static str {
#[cfg(windows)]
{
"NUL"
}
#[cfg(not(windows))]
{
"/dev/null"
}
}
}
impl RemoteBackend for HttpBackend {
fn name(&self) -> &str {
"http"
}
fn download(&self, remote_key: &str, local_path: &Path) -> Result<bool, BackendError> {
let url = self.url_for(remote_key);
let local_path_str = local_path.to_string_lossy().to_string();
let output = self.curl(&["-o", &local_path_str, "-X", "GET", &url])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(BackendError::Transport(format!(
"http GET {url}: curl failed: {stderr}"
)));
}
let (code, _body) = Self::split_status(&output.stdout);
match code {
200..=299 => Ok(true),
404 => {
let _ = std::fs::remove_file(local_path);
Ok(false)
}
_ => Err(BackendError::Transport(format!(
"http GET {url} returned status {code}"
))),
}
}
fn upload(&self, local_path: &Path, remote_key: &str) -> Result<(), BackendError> {
let url = self.url_for(remote_key);
let local_path_str = local_path.to_string_lossy().to_string();
let output = self.curl(&[
"-X",
"PUT",
"--data-binary",
&format!("@{}", local_path_str),
&url,
])?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http PUT {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, body) = Self::split_status(&output.stdout);
if !(200..=299).contains(&code) {
return Err(BackendError::Transport(format!(
"http PUT {url} returned status {code}: {}",
String::from_utf8_lossy(&body)
)));
}
Ok(())
}
fn exists(&self, remote_key: &str) -> Result<bool, BackendError> {
let url = self.url_for(remote_key);
let output = self.curl(&["-I", "-X", "HEAD", &url])?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http HEAD {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, _) = Self::split_status(&output.stdout);
match code {
200..=299 => Ok(true),
404 => Ok(false),
other => Err(BackendError::Transport(format!(
"http HEAD {url} returned status {other}"
))),
}
}
fn delete(&self, remote_key: &str) -> Result<(), BackendError> {
let url = self.url_for(remote_key);
let output = self.curl(&["-X", "DELETE", &url])?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http DELETE {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, _) = Self::split_status(&output.stdout);
match code {
200..=299 | 404 => Ok(()),
other => Err(BackendError::Transport(format!(
"http DELETE {url} returned status {other}"
))),
}
}
fn list(&self, prefix: &str) -> Result<Vec<String>, BackendError> {
let url = format!(
"{}/{}?list={}",
self.config.base_url,
self.config.prefix.trim_end_matches('/'),
urlencode_simple(prefix)
);
let output = self.curl(&["-X", "GET", &url])?;
if !output.status.success() {
return Ok(Vec::new());
}
let (code, body) = Self::split_status(&output.stdout);
if !(200..=299).contains(&code) {
return Ok(Vec::new());
}
let text = String::from_utf8_lossy(&body);
Ok(text
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect())
}
}
pub struct AtomicHttpBackend {
inner: HttpBackend,
}
impl AtomicHttpBackend {
pub fn try_new(config: HttpBackendConfig) -> Result<Self, BackendError> {
if !config.conditional_writes {
return Err(BackendError::Config(
"AtomicHttpBackend requires HttpBackendConfig::conditional_writes=true \
(set RED_HTTP_CONDITIONAL_WRITES=true once your server is verified to \
honor If-Match / If-None-Match)"
.into(),
));
}
Ok(Self {
inner: HttpBackend::new(config),
})
}
pub fn inner(&self) -> &HttpBackend {
&self.inner
}
}
impl RemoteBackend for AtomicHttpBackend {
fn name(&self) -> &str {
self.inner.name()
}
fn download(&self, remote_key: &str, local_path: &Path) -> Result<bool, BackendError> {
self.inner.download(remote_key, local_path)
}
fn upload(&self, local_path: &Path, remote_key: &str) -> Result<(), BackendError> {
self.inner.upload(local_path, remote_key)
}
fn exists(&self, remote_key: &str) -> Result<bool, BackendError> {
self.inner.exists(remote_key)
}
fn delete(&self, remote_key: &str) -> Result<(), BackendError> {
self.inner.delete(remote_key)
}
fn list(&self, prefix: &str) -> Result<Vec<String>, BackendError> {
self.inner.list(prefix)
}
}
impl AtomicRemoteBackend for AtomicHttpBackend {
fn object_version(
&self,
remote_key: &str,
) -> Result<Option<BackendObjectVersion>, BackendError> {
let url = self.inner.url_for(remote_key);
let output = self.inner.curl(&[
"-D",
"-",
"-o",
HttpBackend::null_device(),
"-X",
"HEAD",
&url,
])?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http HEAD {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, body) = HttpBackend::split_status(&output.stdout);
match code {
200..=299 => HttpBackend::header_value(&body, "etag")
.map(BackendObjectVersion::new)
.map(Some)
.ok_or_else(|| BackendError::Internal(format!("http HEAD {url} missing ETag"))),
404 => Ok(None),
401 | 403 => Err(BackendError::Auth(format!(
"http HEAD {url} returned status {code}"
))),
other => Err(BackendError::Transport(format!(
"http HEAD {url} returned status {other}"
))),
}
}
fn upload_conditional(
&self,
local_path: &Path,
remote_key: &str,
condition: ConditionalPut,
) -> Result<BackendObjectVersion, BackendError> {
let url = self.inner.url_for(remote_key);
let local_path_str = local_path.to_string_lossy().to_string();
let condition_header = match &condition {
ConditionalPut::IfAbsent => ("If-None-Match", "*"),
ConditionalPut::IfVersion(version) => ("If-Match", version.token.as_str()),
};
let output = self.inner.curl_owned(
vec![
"-X".into(),
"PUT".into(),
"--data-binary".into(),
format!("@{}", local_path_str),
url.clone(),
],
&[condition_header],
)?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http conditional PUT {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, body) = HttpBackend::split_status(&output.stdout);
match code {
200..=299 => self.object_version(remote_key)?.ok_or_else(|| {
BackendError::Internal(format!("http object '{}' missing after upload", remote_key))
}),
404 | 409 | 412 => Err(BackendError::PreconditionFailed(format!(
"http conditional PUT {url} returned status {code}: {}",
String::from_utf8_lossy(&body)
))),
401 | 403 => Err(BackendError::Auth(format!(
"http conditional PUT {url} returned status {code}"
))),
other => Err(BackendError::Transport(format!(
"http conditional PUT {url} returned status {other}: {}",
String::from_utf8_lossy(&body)
))),
}
}
fn delete_conditional(
&self,
remote_key: &str,
condition: ConditionalDelete,
) -> Result<(), BackendError> {
let url = self.inner.url_for(remote_key);
let ConditionalDelete::IfVersion(version) = condition;
let output = self.inner.curl_owned(
vec!["-X".into(), "DELETE".into(), url.clone()],
&[("If-Match", version.token.as_str())],
)?;
if !output.status.success() {
return Err(BackendError::Transport(format!(
"http conditional DELETE {url}: curl failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let (code, _) = HttpBackend::split_status(&output.stdout);
match code {
200..=299 => Ok(()),
404 | 409 | 412 => Err(BackendError::PreconditionFailed(format!(
"http conditional DELETE {url} returned status {code}"
))),
401 | 403 => Err(BackendError::Auth(format!(
"http conditional DELETE {url} returned status {code}"
))),
other => Err(BackendError::Transport(format!(
"http conditional DELETE {url} returned status {other}"
))),
}
}
}
fn urlencode_simple(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
out.push(byte as char);
}
other => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", other);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_for_strips_leading_slash() {
let backend = HttpBackend::new(
HttpBackendConfig::new("https://store.example/").with_prefix("dbs/prod"),
);
assert_eq!(
backend.url_for("/snapshots/1.snap"),
"https://store.example/dbs/prod/snapshots/1.snap"
);
}
#[test]
fn url_for_with_no_prefix() {
let backend = HttpBackend::new(HttpBackendConfig::new("https://store.example"));
assert_eq!(backend.url_for("a/b"), "https://store.example/a/b");
}
#[test]
fn split_status_parses_curl_output() {
let stdout = b"hello world\nHTTPSTATUS:200";
let (code, body) = HttpBackend::split_status(stdout);
assert_eq!(code, 200);
assert_eq!(body, b"hello world\n");
}
#[test]
fn split_status_handles_404() {
let stdout = b"HTTPSTATUS:404";
let (code, body) = HttpBackend::split_status(stdout);
assert_eq!(code, 404);
assert!(body.is_empty());
}
#[test]
fn urlencode_keeps_path_separators() {
assert_eq!(urlencode_simple("snapshots/2026"), "snapshots/2026");
}
#[test]
fn urlencode_escapes_spaces() {
assert_eq!(urlencode_simple("hello world"), "hello%20world");
}
}