Skip to main content

cinc/backends/
webdav.rs

1use std::path::{Path, PathBuf};
2
3use super::Result;
4
5use crate::{config::WebDavInfo, paths::PathExt, secrets::SecretsApi};
6use reqwest::{
7    Method, StatusCode, {Client, RequestBuilder},
8};
9use tracing::debug;
10
11pub struct WebDavStore<'s> {
12    client: Client,
13    cfg: WebDavInfo,
14    secrets: &'s SecretsApi<'s>,
15}
16
17fn calc_mkdir_all_paths(dir: &Path) -> Vec<PathBuf> {
18    let mut r = dir.components().fold(vec![], |mut bs, v| {
19        if bs.is_empty() {
20            bs.push(PathBuf::from(v.as_os_str()));
21        } else {
22            bs.push(bs[bs.len() - 1].join(Path::new(v.as_os_str())))
23        }
24        bs
25    });
26    if dir.is_absolute() {
27        r.remove(0);
28    }
29    r
30}
31
32impl<'s> WebDavStore<'s> {
33    pub fn new(cfg: WebDavInfo, secrets: &'s SecretsApi) -> Self {
34        Self {
35            client: Client::new(),
36            cfg,
37            secrets,
38        }
39    }
40
41    async fn mk_req_abs(&self, method: Method, url: &str) -> Result<RequestBuilder> {
42        debug!("dispatching {method:?} request to {url}");
43        let psk = self.cfg.psk.as_ref().map(|s| async move {
44            match s {
45                crate::config::Secret::SystemSecret(name) => {
46                    assert!(
47                        self.secrets.available(),
48                        "system secrets must be available to use them in a config"
49                    );
50                    let s = self.secrets.get_item(name).await?;
51                    let sv = s.ok_or_else(|| {
52                        super::BackendError::CouldNotLocateSecret(name.to_owned())
53                    })?;
54                    let s = String::from_utf8(sv).expect("failed to convert from secret to utf8");
55                    Ok::<_, super::BackendError>(s)
56                }
57                crate::config::Secret::Plain(p) => Ok(p.to_owned()),
58            }
59        });
60        let psk = if let Some(p) = psk {
61            Some(p.await?)
62        } else {
63            None
64        };
65        Ok(self
66            .client
67            .request(method, url)
68            .basic_auth(&self.cfg.username, psk.as_deref()))
69    }
70
71    async fn mk_req(&self, method: Method, path: &Path) -> Result<RequestBuilder> {
72        let url = Path::new(&self.cfg.url)
73            .join_good(
74                self.cfg
75                    .root
76                    .join(path)
77                    .to_str()
78                    .expect("failed to concat path"),
79            )
80            .to_str()
81            .unwrap()
82            .to_owned();
83        self.mk_req_abs(method, &url).await
84    }
85
86    /// creates a single directory, requires parents to be created
87    ///
88    /// Requires that dir is already parented to root
89    async fn mkdir_abs(&self, dir: &Path) -> Result<()> {
90        debug!("mkdir for {dir:?}");
91        let url = Path::new(&self.cfg.url)
92            .join_good(dir)
93            .to_str()
94            .unwrap()
95            .to_owned();
96        let resp = self
97            .mk_req_abs(
98                Method::from_bytes(b"MKCOL").expect("failed to make mkcol method"),
99                &url,
100            )
101            .await?
102            .send()
103            .await?;
104        match resp.status() {
105            StatusCode::METHOD_NOT_ALLOWED => {
106                // the collection already exists, this is fine
107                Ok(())
108            }
109            _ => {
110                resp.error_for_status()?;
111                Ok(())
112            }
113        }
114    }
115
116    async fn mkdir_all(&self, dir: &Path) -> Result<()> {
117        let dir = self.cfg.root.join_good(dir);
118        debug!("mkdir all for {dir:?}");
119        let parts = calc_mkdir_all_paths(&dir);
120        for p in parts.into_iter() {
121            self.mkdir_abs(&p).await?;
122        }
123        Ok(())
124    }
125}
126
127impl WebDavStore<'_> {
128    pub async fn write_file(&self, at: &Path, bytes: &[u8]) -> super::Result<()> {
129        debug!("writing to {at:?}");
130        if !self
131            .exists(at.parent().expect("no parent path for file"))
132            .await?
133        {
134            debug!("creating parent directories for {at:?}");
135            self.mkdir_all(at.parent().unwrap()).await?;
136        }
137        let resp = self
138            .mk_req(Method::PUT, at)
139            .await?
140            .body(bytes.to_owned())
141            .send()
142            .await?;
143        if resp.status() == StatusCode::CONFLICT {
144            panic!("invalidly scoped but we should've checked for that?");
145        } else {
146            resp.error_for_status()?;
147        }
148        Ok(())
149    }
150
151    pub async fn read_file(&self, at: &Path) -> super::Result<Vec<u8>> {
152        debug!("read {at:?}");
153        let data = self
154            .mk_req(Method::GET, at)
155            .await?
156            .send()
157            .await?
158            .error_for_status()?;
159        let d = data.bytes().await?;
160        Ok(d.to_vec())
161    }
162
163    pub async fn exists(&self, f: &Path) -> super::Result<bool> {
164        debug!("check exists for {f:?}");
165        let req = self.mk_req(Method::GET, f).await?.send().await?;
166        if req.status() == StatusCode::NOT_FOUND {
167            Ok(false)
168        } else {
169            req.error_for_status()?;
170            Ok(true)
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use std::path::{Path, PathBuf};
178
179    use crate::{backends::webdav::WebDavStore, config::WebDavInfo, secrets::SecretsApi};
180    use test_log::test;
181
182    use super::calc_mkdir_all_paths;
183
184    #[test]
185    fn calc_mkdir_all_paths_gives_individual_segments() {
186        let paths = calc_mkdir_all_paths(Path::new("/hello/world/hmm"));
187        assert_eq!(
188            paths.as_slice(),
189            &["/hello", "/hello/world", "/hello/world/hmm"].map(PathBuf::from)
190        )
191    }
192    async fn test_prefix_case(prefix: &str) {
193        let mut server = mockito::Server::new_async().await;
194        let url = server.url();
195
196        let cinc = server
197            .mock("MKCOL", "/cinc")
198            .with_status(200)
199            .create_async()
200            .await;
201        let hmm = server
202            .mock("MKCOL", "/cinc/hmm")
203            .with_status(201)
204            .create_async()
205            .await;
206        let s = SecretsApi::new_unavailable();
207
208        let store = WebDavStore::new(
209            WebDavInfo {
210                url,
211                username: "".to_owned(),
212                psk: None,
213                root: prefix.into(),
214            },
215            &s,
216        );
217
218        store.mkdir_all(Path::new("hmm")).await.unwrap();
219
220        hmm.assert_async().await;
221        cinc.assert_async().await;
222    }
223
224    #[test(tokio::test)]
225    async fn using_non_absolute_prefix_is_okay() {
226        test_prefix_case("cinc").await;
227    }
228
229    #[test(tokio::test)]
230    async fn using_absolute_prefix_is_okay() {
231        test_prefix_case("/cinc").await;
232    }
233
234    #[test(tokio::test)]
235    async fn mkdir_abs_doesnt_fall_over_on_already_exists() {
236        let mut server = mockito::Server::new_async().await;
237        let url = server.url();
238
239        let hmm = server
240            .mock("MKCOL", "/cinc/hmm")
241            .with_status(405)
242            .create_async()
243            .await;
244        let s = SecretsApi::new_unavailable();
245
246        let store = WebDavStore::new(
247            WebDavInfo {
248                url,
249                username: "".to_owned(),
250                psk: None,
251                root: "cinc".into(),
252            },
253            &s,
254        );
255
256        store.mkdir_abs(Path::new("cinc/hmm")).await.unwrap();
257
258        hmm.assert_async().await;
259    }
260
261    #[test(tokio::test)]
262    async fn using_no_prefix_is_okay() {
263        let mut server = mockito::Server::new_async().await;
264        let url = server.url();
265
266        let hmm = server
267            .mock("MKCOL", "/hmm")
268            .with_status(201)
269            .create_async()
270            .await;
271        let s = SecretsApi::new_unavailable();
272
273        let store = WebDavStore::new(
274            WebDavInfo {
275                url,
276                username: "".to_owned(),
277                psk: None,
278                root: "".into(),
279            },
280            &s,
281        );
282
283        store.mkdir_all(Path::new("hmm")).await.unwrap();
284
285        hmm.assert_async().await;
286    }
287}