~whynothugo/vdirsyncer-rs

0062ffb7ca95c7ea2aeca71480552d2d2afb99bf — Hugo Osvaldo Barrera 5 months ago 388c85c
Implement reading and parsing of the config file

This implements the bulk of the configuration options, with only a few
minor ones missing (notably: singlefile and datetime-related features).
5 files changed, 650 insertions(+), 62 deletions(-)

M Cargo.lock
M vdirsyncer/Cargo.toml
M vdirsyncer/src/config.rs
M vdirsyncer/src/main.rs
A vdirsyncer/src/tls.rs
M Cargo.lock => Cargo.lock +97 -20
@@ 89,6 89,17 @@ dependencies = [
]

[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
 "hermit-abi 0.1.19",
 "libc",
 "winapi",
]

[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 267,7 278,7 @@ dependencies = [
 "hyper-rustls",
 "libdav",
 "log",
 "simple_logger",
 "simple_logger 4.2.0",
 "tokio",
]



@@ 484,6 495,15 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"

[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
 "libc",
]

[[package]]
name = "hermit-abi"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b"


@@ 577,7 597,7 @@ version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
dependencies = [
 "hermit-abi",
 "hermit-abi 0.3.2",
 "rustix",
 "windows-sys 0.48.0",
]


@@ 592,6 612,15 @@ dependencies = [
]

[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
 "either",
]

[[package]]
name = "itoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 614,9 643,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"

[[package]]
name = "libc"
version = "0.2.147"
version = "0.2.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b"

[[package]]
name = "libdav"


@@ 654,7 683,7 @@ dependencies = [
 "log",
 "rand",
 "serde",
 "simple_logger",
 "simple_logger 4.2.0",
 "strum",
 "tokio",
 "toml 0.7.6",


@@ 708,7 737,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
 "hermit-abi",
 "hermit-abi 0.3.2",
 "libc",
]



@@ 856,13 885,27 @@ dependencies = [
 "cc",
 "libc",
 "once_cell",
 "spin",
 "untrusted",
 "spin 0.5.2",
 "untrusted 0.7.1",
 "web-sys",
 "winapi",
]

[[package]]
name = "ring"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b"
dependencies = [
 "cc",
 "getrandom",
 "libc",
 "spin 0.9.8",
 "untrusted 0.9.0",
 "windows-sys 0.48.0",
]

[[package]]
name = "roxmltree"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 892,12 935,12 @@ dependencies = [

[[package]]
name = "rustls"
version = "0.21.7"
version = "0.21.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8"
checksum = "446e14c5cda4f3f30fe71863c34ec70f5ac79d6087097ad0bb433e1be5edf04c"
dependencies = [
 "log",
 "ring",
 "ring 0.17.5",
 "rustls-webpki",
 "sct",
]


@@ 925,12 968,12 @@ dependencies = [

[[package]]
name = "rustls-webpki"
version = "0.101.4"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d93931baf2d282fff8d3a532bbfd7653f734643161b87e3e01e59a04439bf0d"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
 "ring",
 "untrusted",
 "ring 0.17.5",
 "untrusted 0.9.0",
]

[[package]]


@@ 960,8 1003,8 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
dependencies = [
 "ring",
 "untrusted",
 "ring 0.16.20",
 "untrusted 0.7.1",
]

[[package]]


@@ 1038,6 1081,18 @@ dependencies = [

[[package]]
name = "simple_logger"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48047e77b528151aaf841a10a9025f9459da80ba820e425ff7eb005708a76dc7"
dependencies = [
 "atty",
 "colored",
 "log",
 "winapi",
]

[[package]]
name = "simple_logger"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2230cd5c29b815c9b699fb610b49a5ed65588f3509d9f0108be3a885da629333"


@@ 1089,6 1144,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"

[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"

[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1179,9 1240,9 @@ checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb"

[[package]]
name = "tokio"
version = "1.32.0"
version = "1.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653"
dependencies = [
 "backtrace",
 "bytes",


@@ 1338,6 1399,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"

[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"

[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1348,7 1415,17 @@ name = "vdirsyncer"
version = "0.0.1"
dependencies = [
 "anyhow",
 "hyper",
 "hyper-rustls",
 "itertools 0.11.0",
 "libdav",
 "log",
 "rustls",
 "rustls-pemfile",
 "serde",
 "sha2",
 "simple_logger 2.3.0",
 "tokio",
 "toml 0.8.1",
 "vstorage",
]


@@ 1368,7 1445,7 @@ dependencies = [
 "http",
 "hyper",
 "hyper-rustls",
 "itertools",
 "itertools 0.10.5",
 "libdav",
 "log",
 "rand",

M vdirsyncer/Cargo.toml => vdirsyncer/Cargo.toml +10 -0
@@ 12,6 12,16 @@ license = "EUPL-1.2"

[dependencies]
anyhow = "1.0.75"
hyper = "0.14.27"
hyper-rustls = "0.24.1"
itertools = "0.11.0"
libdav = { version = "0.1.0", path = "../libdav" }
log = "0.4.20"
rustls = { version = "0.21.8", features = ["dangerous_configuration"] }
rustls-pemfile = "1.0.3"
serde = { version = "1.0.188", features = ["derive"] }
sha2 = "0.10.2"
simple_logger = { version = "2.3.0", default-features = false, features = ["colors"] }
tokio = { version = "1.33.0", features = ["sync", "macros", "rt"] }
toml = { version = "0.8.1", features = ["parse"], default-features = false }
vstorage = { version = "0.1.0", path = "../vstorage" }

M vdirsyncer/src/config.rs => vdirsyncer/src/config.rs +376 -42
@@ 10,44 10,169 @@ use std::{
    collections::HashMap,
    ffi::OsString,
    marker::PhantomData,
    num::ParseIntError,
    os::unix::prelude::{OsStrExt, OsStringExt},
    path::{Path, PathBuf},
    process::{Command, Stdio},
    sync::Arc,
    time::SystemTime,
};

use anyhow::{bail, Context};
use hyper::client::HttpConnector;
use hyper_rustls::{ConfigBuilderExt, HttpsConnector, HttpsConnectorBuilder};
use itertools::Itertools;
use libdav::auth::Password;
use rustls::{
    client::{ServerCertVerified, ServerCertVerifier},
    CertificateError, ClientConfig, RootCertStore,
};
use serde::Deserialize;
use vstorage::{
    base::{IcsItem, Item, VcardItem},
    filesystem::FilesystemStorage,
    base::{IcsItem, Item, Storage, VcardItem},
    caldav::{CalDavDefinition, CalDavStorage},
    carddav::{CardDavDefinition, CardDavStorage},
    filesystem::{FilesystemDefinition, FilesystemStorage},
    sync::declare::{CollectionDescription, DeclaredMapping, StoragePair, StoragePairBuilder},
    webcal::{WebCalDefinition, WebCalStorage},
    CollectionId,
};

use crate::tls::{
    cert_and_key_from_pemfile, certs_from_pemfile, key_from_pemfile, FingerPrintAndWebPkiVerifier,
    FingerPrintVerifier,
};

/// A deserialised configuration file.
#[derive(Deserialize, Debug)]
pub struct Config {
pub(crate) struct Config {
    general: GeneralSection,
    pair: HashMap<String, PairSection>,
    storage: HashMap<String, StorageSection>,
    #[serde(rename = "pair")]
    pairs: HashMap<String, PairSection>,
    #[serde(rename = "storage")]
    storages: HashMap<String, StorageSection>,
}

#[derive(Deserialize, Debug)]
struct GeneralSection {
    status_path: PathBuf,
// TODO: the Config instance should be consumed when converting into Storages and Pairs.
//       this would reduce a lot of pointless cloning and copying values.
impl Config {
    /// Returns the `status_path`, expanding a leading tilde if present.
    pub(crate) fn status_path(&self) -> Cow<Path> {
        expand_tilde(&self.general.status_path)
    }

    pub(crate) async fn storages(
        &self,
    ) -> anyhow::Result<(
        HashMap<String, Arc<dyn Storage<IcsItem>>>,
        HashMap<String, Arc<dyn Storage<VcardItem>>>,
    )> {
        let mut calendars = HashMap::new();
        let mut address_books = HashMap::new();

        for (name, source) in self.storages.iter() {
            match source.storage().await? {
                EitherStorage::Calendar(c) => {
                    calendars.insert(name.clone(), c);
                }
                EitherStorage::AddressBook(a) => {
                    address_books.insert(name.clone(), a);
                }
            }
        }

        Ok((calendars, address_books))
    }

    pub(crate) fn pairs<'storages>(
        &self,
        calendars: &'storages HashMap<String, Arc<dyn Storage<IcsItem>>>,
        contacts: &'storages HashMap<String, Arc<dyn Storage<VcardItem>>>,
        // TODO: the "previous state" is required here.
    ) -> anyhow::Result<(
        Vec<StoragePair<'storages, IcsItem>>,
        Vec<StoragePair<'storages, VcardItem>>,
    )> {
        let mut calendar_pairs = Vec::new(); // TODO: with_capacity?
        let mut contact_pairs = Vec::new(); // TODO: with_capacity?

        for (name, source) in self.pairs.iter() {
            match (calendars.get(&source.a), calendars.get(&source.b)) {
                (None, None) => {
                    match (contacts.get(&source.a), contacts.get(&source.b)) {
                        (None, None) => {
                            bail!("Pair {} is missing both storages.", name);
                        }
                        (None, Some(_)) => {
                            bail!("Pair {} is missing contacts storage A.", name);
                        }
                        (Some(_), None) => {
                            bail!("Pair {} is missing contacts storage B.", name);
                        }
                        (Some(a), Some(b)) => {
                            contact_pairs.push(create_pair(name, source, a, b));
                        }
                    };
                }
                (None, Some(_)) => {
                    bail!("Pair {} is missing calendar storage A.", name);
                }
                (Some(_), None) => {
                    bail!("Pair {} is missing calendar storage B.", name);
                }
                (Some(a), Some(b)) => {
                    calendar_pairs.push(create_pair(name, source, a, b));
                }
            }
        }
        Ok((calendar_pairs, contact_pairs))
    }
}

impl GeneralSection {
    /// Returns the `status_path`, expanding a leading tilde if present.
    pub fn status_path(&self) -> Cow<Path> {
        let mut iter = self.status_path.as_path().as_os_str().as_bytes().iter();
        if let Some(b'~') = iter.next() {
            if let Some(b'/') = iter.next() {
                #[allow(deprecated)] // Only imperfect on unsupported platforms.
                let home = std::env::home_dir().expect("must resolve home path to expand tilde");
                let home = home.into_os_string().into_vec().into_iter();
                let all = home.chain(iter.copied());
                let os_string = OsString::from_vec(all.collect::<Vec<_>>());
                return Cow::Owned(PathBuf::from(os_string));
fn create_pair<'a, I: Item>(
    name: &str,
    source: &PairSection,
    a: &Arc<dyn Storage<I>>,
    b: &Arc<dyn Storage<I>>,
) -> StoragePair<'a, I> {
    let mut pair = StoragePair::builder(a.clone(), b.clone());
    for cv in &source.collections {
        pair = match cv {
            CollectionValue::All => pair.with_all_from_a().with_all_from_b(),
            CollectionValue::FromA => pair.with_all_from_a(),
            CollectionValue::FromB => pair.with_all_from_b(),
            CollectionValue::Mapped(alias, a, b) => {
                let mapping = DeclaredMapping::Mapped {
                    alias: alias.clone(),
                    a: a.to_description(),
                    b: b.to_description(),
                };
                pair.with_mapping(mapping)
            }
            CollectionValue::Collection(col) => pair.with_mapping(col.to_mapping()),
        };
    }
    pair.build()
}

fn expand_tilde(orig: &PathBuf) -> Cow<Path> {
    let mut iter = orig.as_path().as_os_str().as_bytes().iter();
    if let Some(b'~') = iter.next() {
        if let Some(b'/') = iter.next() {
            #[allow(deprecated)] // Only problematic on unsupported platforms.
            let home = std::env::home_dir().expect("must resolve home path to expand tilde");
            let home = home.into_os_string().into_vec().into_iter();
            let all = home.chain(std::iter::once(b'/')).chain(iter.copied());
            let os_string = OsString::from_vec(all.collect::<Vec<_>>());
            return Cow::Owned(PathBuf::from(os_string));
        }
        Cow::Borrowed(&self.status_path)
    }
    Cow::Borrowed(&orig)
}

#[derive(Deserialize, Debug)]
pub(crate) struct GeneralSection {
    status_path: PathBuf,
}

#[derive(Deserialize, Debug)]


@@ 75,13 200,36 @@ enum CollectionValue {
}

#[derive(Deserialize, Debug)]
pub enum Collection {
enum Collection {
    // TODO: can I re-use vstorage::sync::declare::CollectionDescription here?
    #[serde(rename = "id")]
    Id(String),
    Id(CollectionId),
    #[serde(rename = "href")]
    Href(String),
}

impl Collection {
    // TODO: these would be less inefficient if they consumed their input.

    fn to_description(&self) -> CollectionDescription {
        match self {
            Collection::Id(id) => CollectionDescription::Id { id: id.clone() },
            Collection::Href(href) => CollectionDescription::Href { href: href.clone() },
        }
    }

    fn to_mapping(&self) -> DeclaredMapping {
        match self {
            Collection::Id(id) => DeclaredMapping::Direct {
                description: CollectionDescription::Id { id: id.clone() },
            },
            Collection::Href(href) => DeclaredMapping::Direct {
                description: CollectionDescription::Href { href: href.clone() },
            },
        }
    }
}

#[derive(Deserialize, Debug)]
enum CollectionSpecial {
    #[serde(rename = "all")]


@@ 95,6 243,11 @@ enum CollectionSpecial {
#[derive(Deserialize, Debug)]
#[serde(tag = "type")]
enum StorageSection {
    // TODO: a "protect" flag to protect one side if EVERYTHING is about to be deleted:
    // - off
    // - items: refuses to operate if any item would be deleted.
    // - collection: refuses to operate if a non-empty collection would be emptied or deleted.
    // - storage: refuses to operate if ALL collections would be emptied or deleted.
    #[serde(rename = "filesystem/icalendar")]
    FilesystemIcalendar(Filesystem<IcsItem>),



@@ 111,6 264,60 @@ enum StorageSection {
    Http(Http),
}

enum EitherStorage {
    Calendar(Arc<dyn Storage<IcsItem>>),
    AddressBook(Arc<dyn Storage<VcardItem>>),
}

impl StorageSection {
    pub(crate) async fn storage(&self) -> anyhow::Result<EitherStorage> {
        Ok(match self {
            StorageSection::FilesystemIcalendar(def) => {
                EitherStorage::Calendar(Arc::new(def.to_storage()))
            }
            StorageSection::FilesystemVcard(def) => {
                EitherStorage::AddressBook(Arc::new(def.to_storage()))
            }
            StorageSection::CardDav(carddav) => {
                EitherStorage::AddressBook(Arc::new(carddav.to_storage().await?))
            }
            StorageSection::CalDav(caldav) => {
                EitherStorage::Calendar(Arc::new(caldav.to_storage().await?))
            }
            StorageSection::Http(http) => EitherStorage::Calendar(Arc::new(http.to_storage()?)),
        })
    }

    // If this is a calendar, return the Storage.
    //
    // - Returns None if no storage matches this type.
    // - Returns Some(_) if a storage matches.
    pub(crate) async fn calendar_storage(
        &self,
    ) -> anyhow::Result<Option<Arc<dyn Storage<IcsItem>>>> {
        Ok(match self {
            StorageSection::FilesystemIcalendar(def) => Some(Arc::new(def.to_storage())),
            StorageSection::FilesystemVcard(_) => None,
            StorageSection::CardDav(_) => None,
            StorageSection::CalDav(caldav) => Some(Arc::new(caldav.to_storage().await?)),
            StorageSection::Http(http) => Some(Arc::new(http.to_storage()?)),
        })
    }

    // If this is a calendar, return the Storage.
    pub(crate) async fn contact_storage(
        &self,
    ) -> anyhow::Result<Option<Arc<dyn Storage<VcardItem>>>> {
        Ok(match self {
            StorageSection::FilesystemIcalendar(_) => None,
            StorageSection::FilesystemVcard(def) => Some(Arc::new(def.to_storage())),
            StorageSection::CardDav(carddav) => Some(Arc::new(carddav.to_storage().await?)),
            StorageSection::CalDav(_) => None,
            StorageSection::Http(_) => None,
        })
    }
}

#[derive(Deserialize, Debug)]
struct Filesystem<I: Item> {
    path: PathBuf,


@@ 123,13 330,35 @@ struct Filesystem<I: Item> {
    item: PhantomData<I>,
}

impl<I: Item> Filesystem<I> {
    fn to_storage(&self) -> FilesystemStorage<I> {
        let path = expand_tilde(&self.path);
        FilesystemDefinition::new(path.to_owned().to_path_buf(), self.fileext.clone()).build()
    }
}

#[derive(Deserialize, Debug)]
struct CardDav {
    url: String,
    username: StringOrFetch,
    password: StringOrFetch,
    #[serde(flatten)]
    network_opts: NetworkOptions,
    network_opts: HttpsConfig,
}

impl CardDav {
    async fn to_storage(&self) -> anyhow::Result<CardDavStorage<HttpsConnector<HttpConnector>>> {
        Ok(CardDavDefinition {
            url: self.url.to_string().parse()?,
            auth: libdav::auth::Auth::Basic {
                username: self.username.to_string()?,
                password: Some(self.password.to_password()?),
            },
            connector: self.network_opts.to_connector()?,
        }
        .build()
        .await?)
    }
}

#[derive(Deserialize, Debug)]


@@ 141,29 370,112 @@ struct CalDav {
    // TODO: end_date
    // TODO: item_types
    #[serde(flatten)]
    network_opts: NetworkOptions,
    network_opts: HttpsConfig,
}

impl CalDav {
    async fn to_storage(&self) -> anyhow::Result<CalDavStorage<HttpsConnector<HttpConnector>>> {
        Ok(CalDavDefinition {
            url: self
                .url
                .to_string()?
                .parse()
                .context("parsing caldav URL")?,
            auth: libdav::auth::Auth::Basic {
                username: self.username.to_string()?,
                password: Some(self.password.to_password()?),
            },
            connector: self.network_opts.to_connector()?,
        }
        .build()
        .await?)
    }
}

#[derive(Deserialize, Debug)]
pub struct Http {
pub(crate) struct Http {
    url: StringOrFetch,
    /// A name for the single collection inside this storage.
    collection: String,
    collection: CollectionId,
    #[serde(flatten)]
    network_opts: NetworkOptions,
    https_config: HttpsConfig,
}

impl Http {
    fn to_storage(&self) -> anyhow::Result<WebCalStorage> {
        Ok(WebCalDefinition {
            url: self.url.to_string()?.parse()?,
            collection_name: self.collection.clone(),
        }
        .build()?)
    }
}

#[derive(Deserialize, Debug)]
struct NetworkOptions {
struct HttpsConfig {
    verify: Option<PathBuf>,
    verify_fingerprint: Option<String>,
    #[serde(default)]
    auth: Auth,
    // TODO: auth_cert
    auth_cert: Option<ClientCert>,
    #[serde(default = "default_useragent")]
    useragent: String,
}

impl HttpsConfig {
    fn to_connector(&self) -> anyhow::Result<HttpsConnector<HttpConnector>> {
        let tls_config = ClientConfig::builder().with_safe_defaults();
        let tls_config = match (&self.verify, &self.verify_fingerprint) {
            (None, None) => tls_config
                .with_native_roots()
                .with_certificate_transparency_logs(&[], SystemTime::now()),
            (None, Some(fingerprint)) => {
                let verifier = Arc::from(FingerPrintVerifier::new(&fingerprint)?);
                tls_config.with_custom_certificate_verifier(verifier)
            }
            (Some(path), None) => {
                let mut root_store = RootCertStore::empty();
                for cert in certs_from_pemfile(&path)? {
                    root_store.add(&cert)?;
                }
                tls_config
                    .with_root_certificates(root_store)
                    .with_certificate_transparency_logs(&[], SystemTime::now())
            }
            (Some(path), Some(fingerprint)) => {
                let mut root_store = RootCertStore::empty();
                for cert in certs_from_pemfile(&path)? {
                    root_store.add(&cert)?;
                }
                let verifier =
                    Arc::from(FingerPrintAndWebPkiVerifier::new(&fingerprint, root_store)?);
                tls_config.with_custom_certificate_verifier(verifier)
            }
        };

        let tls_config = match &self.auth_cert {
            None => tls_config.with_no_client_auth(),
            Some(cc) => {
                let (certs, key) = match cc {
                    ClientCert::SingleFile(combined_path) => {
                        cert_and_key_from_pemfile(combined_path)?
                    }
                    ClientCert::SeparateKeyAndCert(crt_path, key_path) => {
                        (certs_from_pemfile(crt_path)?, key_from_pemfile(key_path)?)
                    }
                };
                tls_config.with_client_auth_cert(certs, key)?
            }
        };

        Ok(HttpsConnectorBuilder::new()
            .with_tls_config(tls_config)
            .https_or_http()
            .enable_http1()
            .build())
    }
}

#[derive(Deserialize, Debug, Default)]
#[serde(rename_all = "lowercase")]
enum Auth {


@@ 177,30 489,52 @@ fn default_useragent() -> String {
    String::from("vdirsyncer/2.0.0-alpha0") // FIXME: hard-coded version
}

#[derive(Deserialize, Debug)]
enum ClientCert {
    SingleFile(PathBuf),
    SeparateKeyAndCert(PathBuf, PathBuf),
}

// TODO: singlefile

#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum StringOrFetch {
    Raw(String),
    Fetch(Fetch),
    // TODO: evaluate whether I want 'shell' or 'prompt'.
    Fetch { fetch: Vec<String> },
}

#[derive(Deserialize, Debug)]
struct Fetch {
    // Note: 'shell' and 'prompt' have been dropped.
    fetch: Vec<String>,
impl StringOrFetch {
    fn to_string(&self) -> anyhow::Result<String> {
        match self {
            StringOrFetch::Raw(s) => Ok(s.clone()),
            StringOrFetch::Fetch { fetch } => {
                // TODO: should expand user and normalise paths.
                let mut values = fetch.iter();
                if Some(&String::from("command")) != values.next() {
                    bail!("First word of a fetch directive must be 'command'")
                };
                let cmd = values.next().context("extracting command from 'fetch'")?;
                let output = Command::new(cmd)
                    .args(values)
                    .stdout(Stdio::piped())
                    .output()
                    .context("executing fetch command")?;
                Ok(std::str::from_utf8(&output.stdout)?.trim().to_owned())
            }
        }
    }

    fn to_password(&self) -> anyhow::Result<Password> {
        self.to_string().map(Password::from)
    }
}

pub fn parse_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Config> {
/// Parse a configuration file at `path`.
pub(crate) fn parse_from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Config> {
    let raw = std::fs::read_to_string(path)?;
    let config: Config = toml::from_str(&raw)?;

    Ok(config)
}

impl<I: Item> Filesystem<I> {
    fn build_storage(&self) -> FilesystemStorage<I> {
        todo!() // TODO
    }
}

M vdirsyncer/src/main.rs => vdirsyncer/src/main.rs +1 -0
@@ 3,6 3,7 @@
// SPDX-License-Identifier: EUPL-1.2

mod config;
mod tls;

fn main() -> anyhow::Result<()> {
    let config = config::parse_from_file("/home/hugo/.config/vdirsyncer/config.toml")?;

A vdirsyncer/src/tls.rs => vdirsyncer/src/tls.rs +166 -0
@@ 0,0 1,166 @@
// Copyright 2023 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: EUPL-1.2

//! Helpers used for advanced TLS configuration.
use std::{fs::File, io::BufReader, num::ParseIntError, path::Path, sync::Arc};

use anyhow::{bail, Context};
use rustls::{
    client::{ServerCertVerified, ServerCertVerifier, WebPkiVerifier},
    Certificate, CertificateError, PrivateKey, RootCertStore,
};
use sha2::{Digest, Sha256};

/// Verifies that the fingerprint of a certificate matches.
pub(crate) struct FingerPrintVerifier {
    fingerprint: Vec<u8>,
}

impl FingerPrintVerifier {
    // Create a new verifier from a hexadecimal fingerprint representation.
    pub(crate) fn new(hex_fingerprint: &str) -> anyhow::Result<Self> {
        let fingerprint = (0..hex_fingerprint.len())
            .step_by(2)
            .map(|i| u8::from_str_radix(&hex_fingerprint[i..=i + 1], 16))
            .collect::<Result<Vec<u8>, ParseIntError>>()?;

        Ok(FingerPrintVerifier { fingerprint })
    }
}

impl ServerCertVerifier for FingerPrintVerifier {
    fn verify_server_cert(
        &self,
        end_entity: &Certificate,
        _intermediates: &[Certificate],
        _server_name: &rustls::ServerName,
        _scts: &mut dyn Iterator<Item = &[u8]>,
        _ocsp_response: &[u8],
        _now: std::time::SystemTime,
    ) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
        let fingerprint = Sha256::digest(&end_entity.0).to_vec();

        if self.fingerprint == fingerprint {
            Ok(ServerCertVerified::assertion())
        } else {
            Err(rustls::Error::InvalidCertificate(CertificateError::Other(
                Arc::from(FingerprintError),
            )))
        }
    }
}

#[derive(Debug)]
struct FingerprintError;

impl std::fmt::Display for FingerprintError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("certificate fingerprint does not match expectation")
    }
}

impl std::error::Error for FingerprintError {}

/// Verifies the fingerprint and CA for a certificate.
pub(crate) struct FingerPrintAndWebPkiVerifier(FingerPrintVerifier, WebPkiVerifier);

impl FingerPrintAndWebPkiVerifier {
    pub(crate) fn new(
        hex_fingerprint: &str,
        roots: impl Into<Arc<RootCertStore>>,
    ) -> anyhow::Result<Self> {
        Ok(Self(
            FingerPrintVerifier::new(hex_fingerprint)?,
            WebPkiVerifier::new(roots, None),
        ))
    }
}

impl ServerCertVerifier for FingerPrintAndWebPkiVerifier {
    fn verify_server_cert(
        &self,
        end_entity: &Certificate,
        intermediates: &[Certificate],
        server_name: &rustls::ServerName,
        scts: &mut dyn Iterator<Item = &[u8]>,
        ocsp_response: &[u8],
        now: std::time::SystemTime,
    ) -> Result<ServerCertVerified, rustls::Error> {
        self.0.verify_server_cert(
            end_entity,
            intermediates,
            server_name,
            scts,
            ocsp_response,
            now,
        )?;
        self.1.verify_server_cert(
            end_entity,
            intermediates,
            server_name,
            scts,
            ocsp_response,
            now,
        )
    }
}

/// Load certificates from a PEM-encoded file.
pub(crate) fn certs_from_pemfile(path: &Path) -> anyhow::Result<Vec<Certificate>> {
    let mut reader = BufReader::new(File::open(path)?);
    rustls_pemfile::certs(&mut reader)?
        .into_iter()
        .map(|v| Ok(Certificate(v)))
        .collect()
}

/// Load a keyfile from a PEM-encoded file.
pub(crate) fn key_from_pemfile(path: &Path) -> anyhow::Result<PrivateKey> {
    let mut reader = BufReader::new(File::open(path)?);

    loop {
        match rustls_pemfile::read_one(&mut reader)? {
            Some(
                rustls_pemfile::Item::RSAKey(key)
                | rustls_pemfile::Item::PKCS8Key(key)
                | rustls_pemfile::Item::ECKey(key),
            ) => return Ok(PrivateKey(key)),
            None => break,
            _ => {}
        }
    }

    bail!("no keys found in {}", path.to_string_lossy());
}

/// Load certificates and a key file from a pem-encoded file.
pub(crate) fn cert_and_key_from_pemfile(
    path: &Path,
) -> anyhow::Result<(Vec<Certificate>, PrivateKey)> {
    let mut reader = BufReader::new(File::open(path)?);
    let mut certs = Vec::new();
    let mut raw_key = None;

    loop {
        match rustls_pemfile::read_one(&mut reader)? {
            Some(
                rustls_pemfile::Item::RSAKey(k)
                | rustls_pemfile::Item::PKCS8Key(k)
                | rustls_pemfile::Item::ECKey(k),
            ) => {
                if raw_key.replace(k).is_some() {
                    bail!("multiple keys found in {}", path.to_string_lossy());
                }
            }
            None => break,
            Some(rustls_pemfile::Item::X509Certificate(cert)) => certs.push(Certificate(cert)),
            _ => {}
        }
    }

    let key = raw_key
        .map(|k| PrivateKey(k))
        .with_context(|| format!("no key found in {}", path.to_string_lossy()))?;
    Ok((certs, key))
}