use anyhow::{Context, Result, bail};
use futures::stream::{self, StreamExt};
use opendal::{Operator, services};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use crate::repo::Repo;
use anyhow::anyhow;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::AsyncReadExt;
const UPLOAD_CONCURRENCY: usize = 8;
const CHUNK: usize = 1 << 20;
pub fn object_key(hash: &str) -> String {
format!("objects/{}/{}", &hash[..2], &hash[2..])
}
pub struct Remote {
op: Operator,
rt: tokio::runtime::Runtime,
}
pub fn open(url: &str) -> Result<Remote> {
let op = build_operator(url)?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
Ok(Remote { op, rt })
}
fn build_operator(url: &str) -> Result<Operator> {
let local_path = url.strip_prefix("local:").unwrap_or(url);
let is_s3 = url.starts_with("s3://");
if is_s3 {
let rest = url.trim_start_matches("s3://");
let (bucket, root) = rest.split_once('/').unwrap_or((rest, ""));
if bucket.is_empty() {
bail!("s3 remote needs a bucket: s3://<bucket>[/<root>]");
}
let mut b = services::S3::default().bucket(bucket);
if !root.is_empty() {
b = b.root(root);
}
let region = std::env::var("AWS_REGION").unwrap_or_else(|_| "auto".into());
b = b.region(®ion);
if let Ok(ep) = std::env::var("AWS_ENDPOINT_URL") {
b = b.endpoint(&ep);
}
return Ok(Operator::new(b)?.finish());
}
std::fs::create_dir_all(local_path)
.with_context(|| format!("creating remote dir {local_path}"))?;
let tmp_dir = Path::new(local_path).join(".stowe-tmp");
std::fs::create_dir_all(&tmp_dir)?;
Ok(Operator::new(
services::Fs::default()
.root(local_path)
.atomic_write_dir(&tmp_dir.to_string_lossy()),
)?
.finish())
}
impl Remote {
pub fn exists(&self, key: &str) -> Result<bool> {
self.rt.block_on(async { Ok(self.op.exists(key).await?) })
}
pub fn put_bytes(&self, key: &str, data: &[u8]) -> Result<()> {
self.rt.block_on(async {
self.op.write(key, data.to_vec()).await?;
Ok(())
})
}
pub fn get_bytes(&self, key: &str) -> Result<Vec<u8>> {
self.rt
.block_on(async { Ok(self.op.read(key).await?.to_vec()) })
}
pub fn get_file(&self, key: &str, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = self.get_bytes(key)?;
std::fs::write(dest, bytes)?;
Ok(())
}
pub fn put_files(&self, items: Vec<(String, PathBuf)>) -> Result<usize> {
let total = items.len();
let done = Arc::new(AtomicUsize::new(0));
let show = std::io::stderr().is_terminal();
self.rt.block_on(async {
let op = &self.op;
let done = &done;
let results: Vec<Result<usize>> = stream::iter(items)
.map(|(key, src)| async move {
let r = if op.exists(&key).await? {
Ok(0usize)
} else {
upload_one(op, &key, &src).await?;
Ok(1usize)
};
let n = done.fetch_add(1, Ordering::Relaxed) + 1;
if show {
eprint!("\r\x1b[Kpushing... {n}/{total}");
let _ = std::io::stderr().flush();
}
r
})
.buffer_unordered(UPLOAD_CONCURRENCY)
.collect()
.await;
if show && total > 0 {
eprint!("\r\x1b[K"); let _ = std::io::stderr().flush();
}
let mut uploaded = 0;
for r in results {
uploaded += r?;
}
Ok(uploaded)
})
}
}
async fn upload_one(op: &Operator, key: &str, src: &Path) -> Result<()> {
let mut file = tokio::fs::File::open(src)
.await
.with_context(|| format!("opening {}", src.display()))?;
let mut writer = op.writer(key).await?;
let mut buf = vec![0u8; CHUNK];
loop {
let n = file.read(&mut buf).await?;
if n == 0 {
break;
}
writer.write(buf[..n].to_vec()).await?;
}
writer.close().await?;
Ok(())
}
pub(crate) fn remote_format(
cfg: &crate::model::Config,
name: &str,
url: &str,
) -> crate::mirror::Format {
match cfg.formats.get(name).map(String::as_str) {
Some("backup") => crate::mirror::Format::Backup,
Some("mirror") => crate::mirror::Format::Mirror,
_ if crate::mirror::local_root(url).is_some() => crate::mirror::Format::Mirror,
_ => crate::mirror::Format::Backup,
}
}
pub(crate) fn remote_url(repo: &Repo, name: &str) -> Result<String> {
repo.config()?
.remotes
.get(name)
.cloned()
.ok_or_else(|| anyhow!("no remote named `{name}` - add one: stowe remote add {name} <url>"))
}
pub(crate) fn remote_reachable(url: &str) -> bool {
match crate::mirror::local_root(url) {
Some(root) => root.exists() || root.parent().map(Path::exists).unwrap_or(false),
None => true,
}
}
pub(crate) fn ensure_reachable(
repo: &Repo,
cfg: &crate::model::Config,
name: &str,
url: &str,
) -> Result<()> {
let Some(root) = crate::mirror::local_root(url) else {
return Ok(());
};
if let Some(cmd) = cfg.mounts.get(name) {
run_mount(name, cmd)?;
if on_local_disk(&root) {
bail!(
"`{name}`: the mount command succeeded, but {} is still on your local disk. \
Refusing to write there - the drive would be backed up to the wrong place.",
crate::paths::short(&root)
);
}
}
let known = repo.remote_head(name)?.is_some();
if known && crate::mirror::detect_format(&root) == crate::mirror::Format::Empty {
bail!(
"remote `{name}` ({}) has been pushed to before, but isn't there now. \
Is the drive connected? (refusing to recreate it)",
crate::paths::short(&root)
);
}
if !remote_reachable(url) {
bail!(
"remote `{name}` ({}) isn't reachable. Is the drive connected?",
crate::paths::short(&root)
);
}
Ok(())
}
#[cfg(unix)]
pub(crate) fn on_local_disk(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let device_of = |p: &Path| -> Option<u64> {
let mut cur = Some(p);
while let Some(c) = cur {
if let Ok(md) = std::fs::metadata(c) {
return Some(md.dev());
}
cur = c.parent();
}
None
};
match (device_of(path), device_of(Path::new("/"))) {
(Some(here), Some(root_fs)) => here == root_fs,
_ => false, }
}
#[cfg(not(unix))]
pub(crate) fn on_local_disk(_path: &Path) -> bool {
false
}
pub(crate) fn run_mount(name: &str, cmd: &str) -> Result<()> {
use colored::Colorize;
println!(
"{} {}",
"mounting".dimmed(),
format!("`{name}`: {cmd}").dimmed()
);
#[cfg(windows)]
let status = std::process::Command::new("cmd").args(["/C", cmd]).status();
#[cfg(not(windows))]
let status = std::process::Command::new("sh").arg("-c").arg(cmd).status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => bail!(
"mount command for `{name}` failed (exit {})",
s.code().unwrap_or(1)
),
Err(e) => bail!("could not run the mount command for `{name}`: {e}"),
}
}