Skip to main content

kget/webdav/
mod.rs

1//! WebDAV protocol adapter.
2//!
3//! Handles `webdav://` and `webdavs://` URLs by converting them to
4//! `http://` / `https://` and injecting a `Basic` authentication header
5//! when credentials are embedded in the URL.
6//!
7//! WebDAV files are fetched via ordinary HTTP GET — servers that implement
8//! RFC 4918 always support GET for existing resources, so no special
9//! PROPFIND round-trip is needed for plain file downloads.
10
11use crate::DownloadOptions;
12use crate::config::ProxyConfig;
13use crate::download::download as http_download;
14use crate::optimization::Optimizer;
15use std::error::Error;
16
17/// Returns `true` if the URL uses the `webdav://` or `webdavs://` scheme.
18pub fn is_webdav_url(url: &str) -> bool {
19    let lower = url.to_lowercase();
20    lower.starts_with("webdav://") || lower.starts_with("webdavs://")
21}
22
23/// Downloads a WebDAV resource.
24pub struct WebDavDownloader {
25    http_url: String,
26    output: String,
27    quiet: bool,
28    proxy: ProxyConfig,
29    optimizer: Optimizer,
30    username: Option<String>,
31    password: Option<String>,
32}
33
34impl WebDavDownloader {
35    /// Create a new downloader from a `webdav://` or `webdavs://` URL.
36    ///
37    /// Credentials embedded in the URL (`webdav://user:pass@host/path`) are
38    /// extracted and used for HTTP Basic authentication.
39    pub fn new(
40        url: String,
41        output: String,
42        quiet: bool,
43        proxy: ProxyConfig,
44        optimizer: Optimizer,
45    ) -> Self {
46        let (http_url, username, password) = parse_webdav_url(&url);
47        Self { http_url, output, quiet, proxy, optimizer, username, password }
48    }
49
50    pub fn download(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
51        let mut extra_headers: Vec<(String, String)> = Vec::new();
52
53        if let Some(user) = &self.username {
54            let pass = self.password.as_deref().unwrap_or("");
55            let creds = base64_encode(format!("{user}:{pass}").as_bytes());
56            extra_headers.push(("Authorization".to_string(), format!("Basic {creds}")));
57        }
58
59        let options = DownloadOptions {
60            quiet_mode: self.quiet,
61            output_path: Some(self.output.clone()),
62            verify_iso: false,
63            expected_sha256: None,
64            extra_headers,
65        };
66
67        http_download(
68            &self.http_url,
69            self.proxy.clone(),
70            self.optimizer.clone(),
71            options,
72            None,
73        )
74    }
75}
76
77// ────────────────────────────────────────────────────────────
78// Helpers
79// ────────────────────────────────────────────────────────────
80
81/// Convert `webdav://user:pass@host/path` → (`http://host/path`, user, pass).
82fn parse_webdav_url(url: &str) -> (String, Option<String>, Option<String>) {
83    let (scheme, rest) = if url.to_lowercase().starts_with("webdavs://") {
84        ("https", &url["webdavs://".len()..])
85    } else {
86        ("http", &url["webdav://".len()..])
87    };
88
89    // Check for embedded credentials: user:pass@host/path
90    if let Some(at) = rest.find('@') {
91        let creds = &rest[..at];
92        let host_path = &rest[at + 1..];
93        let http_url = format!("{scheme}://{host_path}");
94        if let Some(colon) = creds.find(':') {
95            return (
96                http_url,
97                Some(creds[..colon].to_string()),
98                Some(creds[colon + 1..].to_string()),
99            );
100        }
101        return (http_url, Some(creds.to_string()), None);
102    }
103
104    (format!("{scheme}://{rest}"), None, None)
105}
106
107/// Minimal RFC 4648 base64 encoder — avoids adding a `base64` crate.
108fn base64_encode(data: &[u8]) -> String {
109    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
110    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
111    for chunk in data.chunks(3) {
112        let b0 = chunk[0] as usize;
113        let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
114        let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
115        out.push(CHARS[b0 >> 2] as char);
116        out.push(CHARS[((b0 & 3) << 4) | (b1 >> 4)] as char);
117        out.push(if chunk.len() > 1 { CHARS[((b1 & 15) << 2) | (b2 >> 6)] as char } else { '=' });
118        out.push(if chunk.len() > 2 { CHARS[b2 & 63] as char } else { '=' });
119    }
120    out
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn parse_plain_webdav() {
129        let (url, user, pass) = parse_webdav_url("webdav://nas.local/files/report.pdf");
130        assert_eq!(url, "http://nas.local/files/report.pdf");
131        assert!(user.is_none() && pass.is_none());
132    }
133
134    #[test]
135    fn parse_webdavs_with_credentials() {
136        let (url, user, pass) = parse_webdav_url("webdavs://alice:secret@cloud.example.com/docs/file.zip");
137        assert_eq!(url, "https://cloud.example.com/docs/file.zip");
138        assert_eq!(user.as_deref(), Some("alice"));
139        assert_eq!(pass.as_deref(), Some("secret"));
140    }
141
142    #[test]
143    fn base64_roundtrip() {
144        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
145        assert_eq!(base64_encode(b"Man"), "TWFu");
146        assert_eq!(base64_encode(b""), "");
147    }
148}