Initial commit
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::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
|
||||
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/dav
|
||||
ErrorLog /dev/stderr
|
||||
CustomLog /dev/stdout combined
|
||||
|
||||
<Directory /var/dav>
|
||||
Dav On
|
||||
AuthType Basic
|
||||
AuthName "WebDAV"
|
||||
AuthUserFile /etc/apache2/htpasswd
|
||||
Require valid-user
|
||||
Options Indexes FollowSymLinks
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
"#;
|
||||
|
||||
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<GenericImage>,
|
||||
pub http: Endpoint,
|
||||
pub accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
impl WebDav {
|
||||
pub fn start() -> ContainerResult<Self> {
|
||||
let image: GenericImage = GenericBuildableImage::new("vandelay-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));
|
||||
|
||||
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<Account> = 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<Vec<AccountSeed>> {
|
||||
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<AccountSeed> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSeed {
|
||||
pub username: String,
|
||||
pub directories: usize,
|
||||
pub files: Vec<SeededFile>,
|
||||
}
|
||||
|
||||
impl AccountSeed {
|
||||
fn new(username: String) -> Self {
|
||||
Self {
|
||||
username,
|
||||
directories: 0,
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user