use std::ffi::OsStr;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use snapdir_core::manifest::Manifest;
use snapdir_core::merkle::Blake3Hasher;
use snapdir_core::store::{Store, StoreError};
use crate::router::{resolve_adapter, Adapter};
const GET_MANIFEST_COMMAND: &str = "get-manifest-command";
const GET_FETCH_FILES_COMMAND: &str = "get-fetch-files-command";
const GET_PUSH_COMMAND: &str = "get-push-command";
#[derive(Debug, Clone)]
pub struct ExternalStore {
store_url: String,
binary: PathBuf,
shell: String,
}
impl ExternalStore {
pub fn new(store_url: &str) -> Result<Self, StoreError> {
let adapter = resolve_adapter(store_url).map_err(|e| StoreError::Backend {
message: e.to_string(),
source: Some(Box::new(e)),
})?;
match adapter {
Adapter::External { .. } => Ok(Self::with_binary(store_url, adapter.store_binary())),
builtin => Err(StoreError::Backend {
message: format!(
"store protocol resolves to built-in adapter '{}' served in-process, \
not via the external-store shim",
builtin.name()
),
source: None,
}),
}
}
#[must_use]
pub fn with_binary(store_url: &str, binary: impl Into<PathBuf>) -> Self {
Self {
store_url: store_url.to_owned(),
binary: binary.into(),
shell: "bash".to_owned(),
}
}
#[must_use]
pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
self.shell = shell.into();
self
}
#[must_use]
pub fn binary(&self) -> &Path {
&self.binary
}
fn emit(
&self,
subcommand: &str,
args: &[&OsStr],
stdin: Option<&[u8]>,
) -> Result<String, StoreError> {
let mut cmd = Command::new(&self.binary);
cmd.arg(subcommand)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn().map_err(|e| StoreError::Backend {
message: format!("failed to spawn store binary '{}'", self.binary.display()),
source: Some(Box::new(e)),
})?;
if let Some(bytes) = stdin {
let mut sink = child.stdin.take().ok_or_else(|| StoreError::Backend {
message: "store binary stdin unavailable".to_owned(),
source: None,
})?;
sink.write_all(bytes)?;
drop(sink);
}
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(StoreError::Backend {
message: format!(
"store binary '{}' {} exited with {}: {}",
self.binary.display(),
subcommand,
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
source: None,
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn eval(&self, script: &str, stdin: Option<&[u8]>) -> Result<EvalOutput, StoreError> {
let wrapped = format!("set -eEuo pipefail;\ntrap 'kill 0' INT;\n{script}\nwait");
let mut cmd = Command::new(&self.shell);
cmd.arg("-c")
.arg(&wrapped)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn().map_err(|e| StoreError::Backend {
message: format!("failed to spawn shell '{}'", self.shell),
source: Some(Box::new(e)),
})?;
if let Some(bytes) = stdin {
let mut sink = child.stdin.take().ok_or_else(|| StoreError::Backend {
message: "shell stdin unavailable".to_owned(),
source: None,
})?;
sink.write_all(bytes)?;
drop(sink);
}
let output = child.wait_with_output()?;
Ok(EvalOutput {
success: output.status.success(),
code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
struct EvalOutput {
success: bool,
code: Option<i32>,
stdout: String,
stderr: String,
}
impl Store for ExternalStore {
fn get_manifest(&self, id: &str) -> Result<Manifest, StoreError> {
let args: [&OsStr; 4] = [
OsStr::new("--id"),
OsStr::new(id),
OsStr::new("--store"),
OsStr::new(&self.store_url),
];
let script = self.emit(GET_MANIFEST_COMMAND, &args, None)?;
let out = self.eval(&script, None)?;
if !out.success {
if out.stderr.contains("not found on --store") {
return Err(StoreError::ManifestNotFound { id: id.to_owned() });
}
return Err(StoreError::Backend {
message: format!(
"{GET_MANIFEST_COMMAND} script for id '{id}' failed (exit {}): {}",
out.code.unwrap_or(-1),
out.stderr.trim()
),
source: None,
});
}
let manifest = Manifest::parse(&out.stdout)?;
let hasher = Blake3Hasher;
let actual = snapdir_core::snapshot_id(&manifest, &hasher);
if actual != id {
return Err(StoreError::Integrity {
address: snapdir_core::store::manifest_path(id),
expected: id.to_owned(),
actual,
});
}
Ok(manifest)
}
fn fetch_files(&self, manifest: &Manifest, dest: &Path) -> Result<(), StoreError> {
let hasher = Blake3Hasher;
let id = snapdir_core::snapshot_id(manifest, &hasher);
let manifest_text = manifest.to_string();
let args: [&OsStr; 6] = [
OsStr::new("--id"),
OsStr::new(&id),
OsStr::new("--store"),
OsStr::new(&self.store_url),
OsStr::new("--cache-dir"),
dest.as_os_str(),
];
let script = self.emit(
GET_FETCH_FILES_COMMAND,
&args,
Some(manifest_text.as_bytes()),
)?;
let out = self.eval(&script, None)?;
if !out.success {
return Err(StoreError::Backend {
message: format!(
"{GET_FETCH_FILES_COMMAND} script for id '{id}' failed (exit {}): {}",
out.code.unwrap_or(-1),
out.stderr.trim()
),
source: None,
});
}
if out.stdout.contains("ERROR:") || out.stderr.contains("ERROR:") {
return Err(StoreError::Backend {
message: format!(
"{GET_FETCH_FILES_COMMAND} transaction for id '{id}' reported an error: {}",
out.stderr.trim()
),
source: None,
});
}
Ok(())
}
fn push(&self, manifest: &Manifest, source: &Path) -> Result<(), StoreError> {
let hasher = Blake3Hasher;
let id = snapdir_core::snapshot_id(manifest, &hasher);
let args: [&OsStr; 6] = [
OsStr::new("--id"),
OsStr::new(&id),
OsStr::new("--staging-dir"),
source.as_os_str(),
OsStr::new("--store"),
OsStr::new(&self.store_url),
];
let script = self.emit(GET_PUSH_COMMAND, &args, None)?;
let out = self.eval(&script, None)?;
if !out.success {
return Err(StoreError::Backend {
message: format!(
"{GET_PUSH_COMMAND} script for id '{id}' failed (exit {}): {}",
out.code.unwrap_or(-1),
out.stderr.trim()
),
source: None,
});
}
if out.stdout.contains("ERROR:") || out.stderr.contains("ERROR:") {
return Err(StoreError::Backend {
message: format!(
"{GET_PUSH_COMMAND} transaction for id '{id}' reported an error: {}",
out.stderr.trim()
),
source: None,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shim_new_rejects_builtin_adapters() {
for url in ["file:///x", "s3://b/x", "b2://b/x", "gs://b/x"] {
let err = ExternalStore::new(url).unwrap_err();
assert!(
matches!(err, StoreError::Backend { .. }),
"expected Backend error for built-in {url}, got {err:?}"
);
}
}
#[test]
fn shim_new_resolves_third_party_binary_from_protocol() {
let store = ExternalStore::new("mock://bucket/base").unwrap();
assert_eq!(store.binary(), Path::new("snapdir-mock-store"));
}
}