use std::path::{Path, PathBuf};
use super::Result;
use crate::{config::WebDavInfo, paths::PathExt, secrets::SecretsApi};
use reqwest::{
Method, StatusCode, {Client, RequestBuilder},
};
use tracing::debug;
pub struct WebDavStore<'s> {
client: Client,
cfg: WebDavInfo,
secrets: &'s SecretsApi<'s>,
}
fn calc_mkdir_all_paths(dir: &Path) -> Vec<PathBuf> {
let mut r = dir.components().fold(vec![], |mut bs, v| {
if bs.is_empty() {
bs.push(PathBuf::from(v.as_os_str()));
} else {
bs.push(bs[bs.len() - 1].join(Path::new(v.as_os_str())))
}
bs
});
if dir.is_absolute() {
r.remove(0);
}
r
}
impl<'s> WebDavStore<'s> {
pub fn new(cfg: WebDavInfo, secrets: &'s SecretsApi) -> Self {
Self {
client: Client::new(),
cfg,
secrets,
}
}
async fn mk_req_abs(&self, method: Method, url: &str) -> Result<RequestBuilder> {
debug!("dispatching {method:?} request to {url}");
let psk = self.cfg.psk.as_ref().map(|s| async move {
match s {
crate::config::Secret::SystemSecret(name) => {
assert!(
self.secrets.available(),
"system secrets must be available to use them in a config"
);
let s = self.secrets.get_item(name).await?;
let sv = s.ok_or_else(|| {
super::BackendError::CouldNotLocateSecret(name.to_owned())
})?;
let s = String::from_utf8(sv).expect("failed to convert from secret to utf8");
Ok::<_, super::BackendError>(s)
}
crate::config::Secret::Plain(p) => Ok(p.to_owned()),
}
});
let psk = if let Some(p) = psk {
Some(p.await?)
} else {
None
};
Ok(self
.client
.request(method, url)
.basic_auth(&self.cfg.username, psk.as_deref()))
}
async fn mk_req(&self, method: Method, path: &Path) -> Result<RequestBuilder> {
let url = Path::new(&self.cfg.url)
.join_good(
self.cfg
.root
.join(path)
.to_str()
.expect("failed to concat path"),
)
.to_str()
.unwrap()
.to_owned();
self.mk_req_abs(method, &url).await
}
async fn mkdir_abs(&self, dir: &Path) -> Result<()> {
debug!("mkdir for {dir:?}");
let url = Path::new(&self.cfg.url)
.join_good(dir)
.to_str()
.unwrap()
.to_owned();
let resp = self
.mk_req_abs(
Method::from_bytes(b"MKCOL").expect("failed to make mkcol method"),
&url,
)
.await?
.send()
.await?;
match resp.status() {
StatusCode::METHOD_NOT_ALLOWED => {
Ok(())
}
_ => {
resp.error_for_status()?;
Ok(())
}
}
}
async fn mkdir_all(&self, dir: &Path) -> Result<()> {
let dir = self.cfg.root.join_good(dir);
debug!("mkdir all for {dir:?}");
let parts = calc_mkdir_all_paths(&dir);
for p in parts.into_iter() {
self.mkdir_abs(&p).await?;
}
Ok(())
}
}
impl WebDavStore<'_> {
pub async fn write_file(&self, at: &Path, bytes: &[u8]) -> super::Result<()> {
debug!("writing to {at:?}");
if !self
.exists(at.parent().expect("no parent path for file"))
.await?
{
debug!("creating parent directories for {at:?}");
self.mkdir_all(at.parent().unwrap()).await?;
}
let resp = self
.mk_req(Method::PUT, at)
.await?
.body(bytes.to_owned())
.send()
.await?;
if resp.status() == StatusCode::CONFLICT {
panic!("invalidly scoped but we should've checked for that?");
} else {
resp.error_for_status()?;
}
Ok(())
}
pub async fn read_file(&self, at: &Path) -> super::Result<Vec<u8>> {
debug!("read {at:?}");
let data = self
.mk_req(Method::GET, at)
.await?
.send()
.await?
.error_for_status()?;
let d = data.bytes().await?;
Ok(d.to_vec())
}
pub async fn exists(&self, f: &Path) -> super::Result<bool> {
debug!("check exists for {f:?}");
let req = self.mk_req(Method::GET, f).await?.send().await?;
if req.status() == StatusCode::NOT_FOUND {
Ok(false)
} else {
req.error_for_status()?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::{backends::webdav::WebDavStore, config::WebDavInfo, secrets::SecretsApi};
use test_log::test;
use super::calc_mkdir_all_paths;
#[test]
fn calc_mkdir_all_paths_gives_individual_segments() {
let paths = calc_mkdir_all_paths(Path::new("/hello/world/hmm"));
assert_eq!(
paths.as_slice(),
&["/hello", "/hello/world", "/hello/world/hmm"].map(PathBuf::from)
)
}
async fn test_prefix_case(prefix: &str) {
let mut server = mockito::Server::new_async().await;
let url = server.url();
let cinc = server
.mock("MKCOL", "/cinc")
.with_status(200)
.create_async()
.await;
let hmm = server
.mock("MKCOL", "/cinc/hmm")
.with_status(201)
.create_async()
.await;
let s = SecretsApi::new_unavailable();
let store = WebDavStore::new(
WebDavInfo {
url,
username: "".to_owned(),
psk: None,
root: prefix.into(),
},
&s,
);
store.mkdir_all(Path::new("hmm")).await.unwrap();
hmm.assert_async().await;
cinc.assert_async().await;
}
#[test(tokio::test)]
async fn using_non_absolute_prefix_is_okay() {
test_prefix_case("cinc").await;
}
#[test(tokio::test)]
async fn using_absolute_prefix_is_okay() {
test_prefix_case("/cinc").await;
}
#[test(tokio::test)]
async fn mkdir_abs_doesnt_fall_over_on_already_exists() {
let mut server = mockito::Server::new_async().await;
let url = server.url();
let hmm = server
.mock("MKCOL", "/cinc/hmm")
.with_status(405)
.create_async()
.await;
let s = SecretsApi::new_unavailable();
let store = WebDavStore::new(
WebDavInfo {
url,
username: "".to_owned(),
psk: None,
root: "cinc".into(),
},
&s,
);
store.mkdir_abs(Path::new("cinc/hmm")).await.unwrap();
hmm.assert_async().await;
}
#[test(tokio::test)]
async fn using_no_prefix_is_okay() {
let mut server = mockito::Server::new_async().await;
let url = server.url();
let hmm = server
.mock("MKCOL", "/hmm")
.with_status(201)
.create_async()
.await;
let s = SecretsApi::new_unavailable();
let store = WebDavStore::new(
WebDavInfo {
url,
username: "".to_owned(),
psk: None,
root: "".into(),
},
&s,
);
store.mkdir_all(Path::new("hmm")).await.unwrap();
hmm.assert_async().await;
}
}