/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::time::Duration; use encodify::base64::STANDARD as B64; use testcontainers::core::{IntoContainerPort, WaitFor}; use testcontainers::runners::{SyncBuilder, SyncRunner}; use testcontainers::{Container, GenericBuildableImage, GenericImage, ImageExt}; use super::dav_client::DavSeed; use super::error::{ContainerError, ContainerResult}; use super::layouts::{self, FileSpec}; use super::{Account, Endpoint}; const HTTP_PORT: u16 = 80; const DOCKERFILE: &str = r#"FROM debian:bookworm-20260518-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ apache2 apache2-utils ca-certificates && \ a2enmod dav dav_fs auth_basic authn_file && \ rm -rf /var/lib/apt/lists/* COPY apache.conf /etc/apache2/sites-available/000-default.conf COPY htpasswd /etc/apache2/htpasswd RUN mkdir -p /var/dav && \ chown -R www-data:www-data /var/dav EXPOSE 80 CMD ["apachectl", "-D", "FOREGROUND"] "#; const APACHE_CONF: &str = r#"DavLockDB /tmp/DavLock DocumentRoot /var/dav ErrorLog /dev/stderr CustomLog /dev/stdout combined Dav On AuthType Basic AuthName "WebDAV" AuthUserFile /etc/apache2/htpasswd Require valid-user Options Indexes FollowSymLinks "#; fn htpasswd_for_test() -> String { let mut out = String::new(); for u in layouts::accounts() { out.push_str(u); out.push(':'); out.push_str("{SHA}"); out.push_str(&sha1_b64(layouts::PASSWORD.as_bytes())); out.push('\n'); } out } fn sha1_b64(bytes: &[u8]) -> String { let digest = simple_sha1(bytes); B64.encode(digest) } fn simple_sha1(bytes: &[u8]) -> [u8; 20] { let mut h0: u32 = 0x6745_2301; let mut h1: u32 = 0xEFCD_AB89; let mut h2: u32 = 0x98BA_DCFE; let mut h3: u32 = 0x1032_5476; let mut h4: u32 = 0xC3D2_E1F0; let bit_len = (bytes.len() as u64).wrapping_mul(8); let mut msg = bytes.to_vec(); msg.push(0x80); while msg.len() % 64 != 56 { msg.push(0); } msg.extend_from_slice(&bit_len.to_be_bytes()); for chunk in msg.chunks(64) { let mut w = [0u32; 80]; for (i, word) in w.iter_mut().take(16).enumerate() { *word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap()); } for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); } let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4); for (i, &wi) in w.iter().enumerate() { let (f, k): (u32, u32) = match i { 0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999), 20..=39 => (b ^ c ^ d, 0x6ED9_EBA1), 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC), _ => (b ^ c ^ d, 0xCA62_C1D6), }; let temp = a .rotate_left(5) .wrapping_add(f) .wrapping_add(e) .wrapping_add(k) .wrapping_add(wi); e = d; d = c; c = b.rotate_left(30); b = a; a = temp; } h0 = h0.wrapping_add(a); h1 = h1.wrapping_add(b); h2 = h2.wrapping_add(c); h3 = h3.wrapping_add(d); h4 = h4.wrapping_add(e); } let mut out = [0u8; 20]; out[..4].copy_from_slice(&h0.to_be_bytes()); out[4..8].copy_from_slice(&h1.to_be_bytes()); out[8..12].copy_from_slice(&h2.to_be_bytes()); out[12..16].copy_from_slice(&h3.to_be_bytes()); out[16..20].copy_from_slice(&h4.to_be_bytes()); out } pub struct WebDav { container: Container, pub http: Endpoint, pub accounts: Vec, } impl WebDav { pub fn start() -> ContainerResult { let image: GenericImage = GenericBuildableImage::new("inbuxa-migrate-webdav", "test") .with_dockerfile_string(DOCKERFILE.to_owned()) .with_data(APACHE_CONF.as_bytes().to_vec(), "apache.conf") .with_data(htpasswd_for_test().into_bytes(), "htpasswd") .build_image() .map_err(|e| ContainerError::Seed(format!("webdav build: {e}")))?; let request = image .with_exposed_port(HTTP_PORT.tcp()) .with_wait_for(WaitFor::message_on_stderr("AH00558")) .with_startup_timeout(Duration::from_secs(120)) .with_labels([(super::OWNER_LABEL, "1")]); let container = request.start()?; let host = container.get_host()?.to_string(); let http = Endpoint::new(host, container.get_host_port_ipv4(HTTP_PORT.tcp())?); let accounts: Vec = layouts::accounts() .iter() .map(|name| Account { username: (*name).to_owned(), password: layouts::PASSWORD.to_owned(), layout: layouts::layout_for(name), }) .collect(); Ok(Self { container, http, accounts, }) } pub fn base_url(&self) -> String { self.http.http_base() } pub fn account_url(&self, account: &Account) -> String { format!("{}/{}/", self.base_url(), account.username) } pub fn seed_all(&self) -> ContainerResult> { let mut out = Vec::new(); for acct in &self.accounts { let m = self.seed_account(acct)?; out.push(m); } Ok(out) } pub fn seed_account(&self, account: &Account) -> ContainerResult { let client = DavSeed::new(self.base_url(), &account.username, &account.password); let root = format!("/{}/", account.username); client.mkcol(&root, None)?; let mut seed = AccountSeed::new(account.username.clone()); let specs = account.layout.files; for spec in specs { let path = build_path(&root, specs, spec.key) .ok_or_else(|| ContainerError::Seed(format!("missing file key: {}", spec.key)))?; if spec.directory { client.mkcol(&path, None)?; seed.directories += 1; } else { let payload = synth_payload(spec.name); client.put(&path, "application/octet-stream", payload.as_bytes())?; seed.files.push(SeededFile { key: spec.key.to_owned(), name: spec.name.to_owned(), href: path, payload: payload.into_bytes(), }); } } Ok(seed) } pub fn delete_resource(&self, account: &Account, href: &str) -> ContainerResult<()> { let client = DavSeed::new(self.base_url(), &account.username, &account.password); client.delete(href)?; Ok(()) } pub fn add_file( &self, account: &Account, parent_segments: &[&str], name: &str, payload: &[u8], ) -> ContainerResult { let client = DavSeed::new(self.base_url(), &account.username, &account.password); let mut href = format!("/{}/", account.username); for seg in parent_segments { href.push_str(seg); href.push('/'); } href.push_str(name); client.put(&href, "application/octet-stream", payload)?; Ok(href) } pub fn verify_seed(&self) -> ContainerResult<()> { for acct in &self.accounts { let client = DavSeed::new(self.base_url(), &acct.username, &acct.password); let body = client.propfind(&format!("/{}/", acct.username), 1)?; if !body.contains("multistatus") { return Err(ContainerError::Protocol(format!( "webdav propfind for {} returned no multistatus", acct.username ))); } } Ok(()) } pub fn stop(self) -> ContainerResult<()> { self.container.stop()?; Ok(()) } } fn build_path(root: &str, specs: &[FileSpec], key: &str) -> Option { let mut chain: Vec<&str> = Vec::new(); let mut cur = key; let mut leaf_dir = false; loop { let spec = specs.iter().find(|s| s.key == cur)?; chain.push(spec.name); if chain.len() == 1 { leaf_dir = spec.directory; } match spec.parent { Some(p) => cur = p, None => break, } } chain.reverse(); let mut path = String::from(root); for (i, name) in chain.iter().enumerate() { path.push_str(name); let is_last = i + 1 == chain.len(); if !is_last || leaf_dir { path.push('/'); } } Some(path) } pub fn synth_payload(name: &str) -> String { let mut out = String::new(); out.push_str(name); out.push('\n'); for i in 0..16 { out.push_str(&format!("line {i} for {name}\n")); } out } #[derive(Debug, Clone)] pub struct SeededFile { pub key: String, pub name: String, pub href: String, pub payload: Vec, } #[derive(Debug, Clone)] pub struct AccountSeed { pub username: String, pub directories: usize, pub files: Vec, } impl AccountSeed { fn new(username: String) -> Self { Self { username, directories: 0, files: Vec::new(), } } }