~whynothugo/vdirsyncer-rs

b1220f51e043a768acb77c0338c0cd1beb1bde3b — Hugo Osvaldo Barrera 1 year, 12 days ago 58aa25e legacy-config
WIP: implement parser for legacy config

This is incomplete and doesn't work. It is committed here in case it is
necessary in future, but it's not going to be merged into the main
branch any time soon.
M Cargo.lock => Cargo.lock +110 -1
@@ 222,6 222,28 @@ dependencies = [
]

[[package]]
name = "const-random"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368a7a772ead6ce7e1de82bfb04c485f3db8ec744f72925af5735e29a22cc18e"
dependencies = [
 "const-random-macro",
 "proc-macro-hack",
]

[[package]]
name = "const-random-macro"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d7d6ab3c3a2282db210df5f02c4dab6e0a7057af0fb7ebd4070f30fe05c0ddb"
dependencies = [
 "getrandom",
 "once_cell",
 "proc-macro-hack",
 "tiny-keccak",
]

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


@@ 247,6 269,12 @@ dependencies = [
]

[[package]]
name = "crunchy"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"

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


@@ 282,6 310,15 @@ dependencies = [
]

[[package]]
name = "dlv-list"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d529fd73d344663edfd598ccb3f344e46034db51ebd103518eae34338248ad73"
dependencies = [
 "const-random",
]

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


@@ 464,6 501,12 @@ checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"

[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"

[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"


@@ 554,13 597,17 @@ dependencies = [
]

[[package]]
name = "icalendar_parser"
version = "0.1.0"

[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
 "equivalent",
 "hashbrown",
 "hashbrown 0.14.0",
]

[[package]]


@@ 726,6 773,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"

[[package]]
name = "ordered-multimap"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ed8acf08e98e744e5384c8bc63ceb0364e68a6854187221c18df61c4797690e"
dependencies = [
 "dlv-list",
 "hashbrown 0.13.2",
]

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


@@ 773,6 830,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"

[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"

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


@@ 853,6 916,16 @@ dependencies = [
]

[[package]]
name = "rust-ini"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091"
dependencies = [
 "cfg-if",
 "ordered-multimap",
]

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


@@ 921,6 994,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"

[[package]]
name = "ryu"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"

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


@@ 989,6 1068,17 @@ dependencies = [
]

[[package]]
name = "serde_json"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
dependencies = [
 "itoa",
 "ryu",
 "serde",
]

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


@@ 1142,6 1232,15 @@ dependencies = [
]

[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
 "crunchy",
]

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


@@ 1285,6 1384,16 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "vdirsyncer"
version = "0.0.1"
dependencies = [
 "http",
 "hyper",
 "hyper-rustls",
 "libdav",
 "rust-ini",
 "serde_json",
 "thiserror",
 "vstorage",
]

[[package]]
name = "version_check"

M vdirsyncer/Cargo.toml => vdirsyncer/Cargo.toml +8 -0
@@ 13,3 13,11 @@ license = "EUPL-1.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rust-ini = "0.19.0"
serde_json = "1.0.97"
thiserror = "1.0.40"
vstorage = { version = "0.1.0", path = "../vstorage" }
libdav = { version = "0.1.0", path = "../libdav" }
http = "0.2.9"
hyper = "0.14.27"
hyper-rustls = "0.24.1"

A vdirsyncer/src/config.rs => vdirsyncer/src/config.rs +423 -0
@@ 0,0 1,423 @@
#![allow(dead_code)]
use std::{collections::HashMap, path::PathBuf};

use hyper::client::HttpConnector;
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use ini::Ini;
use libdav::auth::Auth;
use serde_json::Value;
use vstorage::{
    base::{Definition, IcsItem, Item, VcardItem},
    caldav::CalDavDefinition,
    carddav::CardDavDefinition,
    filesystem::{FilesystemDefinition, PropertyWithFilename},
    webcal::WebCalDefinition,
};

use self::fetch::{FetchError, FetchedProperties};

mod fetch;

#[derive(Debug)]
enum Collection {
    AllFromA,
    AllFromB,
    Mapped {
        config_name: String,
        a: String,
        b: String,
    },
    Named(String),
}

/// The configuration for a Pair.
///
/// Not to be confused with [`vstorage::sync::declare::StoragePair`]. This data type mostly holds
/// the information provided by the user that is required to build the `StoragePair` itself.
#[derive(Debug)]
pub struct Pair {
    a: String,
    b: String,
    collections: Option<Vec<Collection>>,
    // TODO: conflict_resolution
    // TODO: partial_sync
    // TODO: metadata
}

/// An error parsing a pair.
#[derive(Debug, thiserror::Error)]
pub enum PairError {
    #[error("Missing storage 'a' in pair definition")]
    MissingA,

    #[error("Missing storage 'b' in pair definition")]
    MissingB,

    #[error("Missing 'collections' in pair definition")]
    MissingCollections,

    #[error("Could not parse 'collections' in pair definition: {0}")]
    CollectionsBadData(serde_json::error::Error),

    #[error("Each collection must be a single string or an array of three strings")]
    BadCollection,
}

impl Pair {
    fn parse(props: &ini::Properties) -> Result<Pair, PairError> {
        let a = props.get("a").ok_or(PairError::MissingA)?.to_string();
        let b = props.get("b").ok_or(PairError::MissingB)?.to_string();
        let raw_collections = props
            .get("collections")
            .ok_or(PairError::MissingCollections)?;

        let json_collections = serde_json::from_str::<Option<Vec<Value>>>(raw_collections)
            .map_err(PairError::CollectionsBadData)?;
        let collections = match json_collections {
            None => None,
            Some(items) => {
                let mut c = Vec::new();
                for item in items {
                    let collection = match item {
                        Value::String(s) => match s.as_str() {
                            "from a" => Collection::AllFromA,
                            "from b" => Collection::AllFromB,
                            s => Collection::Named(s.to_string()),
                        },
                        Value::Array(array) => {
                            if array.len() != 3 {
                                return Err(PairError::BadCollection);
                            }
                            Collection::Mapped {
                                config_name: array[0].to_string(),
                                a: array[1].to_string(),
                                b: array[2].to_string(),
                            }
                        }
                        _ => return Err(PairError::BadCollection),
                    };
                    c.push(collection);
                }

                Some(c)
            }
        };

        Ok(Pair { a, b, collections })
    }
}

/// The configuration for a single storage.
///
/// Not to be confused with [`vstorage::base::Storage`]
#[derive(Debug)]
pub struct Storage {
    // TODO: type
    read_only: bool,
    // FIXME: not only icsitem. Maybe use an enum with both types?
    definition: Box<dyn Definition<IcsItem>>,
}

/// An error parsing a storage.
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
    #[error("Error resoving fetch argument: {0}")]
    Fetch(#[from] FetchError),

    #[error("Missing 'type' in storage definition")]
    MissingType,

    #[error("Missing field '{0}' in storage definition")]
    MissingField(&'static str),

    #[error("Failed to parse 'url' in storage definition")]
    InvalidUrl(http::uri::InvalidUri),

    #[error("Unknown storage type: '{0}'")]
    UnknownStorageType(String),

    #[error("Unknown argument '{0}' for storage")]
    UnknownArgument(String),

    #[error("Storage type {0} cannot include `read_only = false`")]
    MustBeReadOnly(&'static str),

    #[error("Invalid value for argument '{0}': {1}")]
    InvalidValue(&'static str, serde_json::Error),
}

impl Storage {
    fn parse(props: FetchedProperties) -> Result<Storage, StorageError> {
        let storage_type = props.get("type")?.ok_or(StorageError::MissingType)?;

        // "None" means "nothing explicitly specified.
        let mut read_only = match props.get("read_only")? {
            Some(r) => Some(
                serde_json::from_str::<bool>(r.as_ref())
                    .map_err(|e| StorageError::InvalidValue("read_only", e))?,
            ),
            None => None,
        };

        let definition: Box<dyn Definition<IcsItem>> = match storage_type.as_ref() {
            "caldav" => Box::new(CalDavDefinition::from_config(props)?),
            // FIXME: WRONG TYPE!!!
            "carddav" => Box::new(CalDavDefinition::from_config(props)?),
            "google_calendar" => todo!("google calendar is not yet implemented"),
            "google_contacts" => todo!("google contacts is not yet implemented"),
            "filesystem" => Box::new(FilesystemDefinition::from_config(props)?),
            "singlefile" => todo!("singlefile is not yet implemented"),
            "http" => {
                if let Some(false) = read_only {
                    return Err(StorageError::MustBeReadOnly("http"));
                }
                read_only = Some(true);
                Box::new(WebCalDefinition::from_config(props)?)
            }
            s => return Err(StorageError::UnknownStorageType(s.to_string())),
        };

        Ok(Storage {
            read_only: read_only.unwrap_or(false),
            definition,
        })
    }
}

trait FromConfigProps<I: Item>: Definition<I>
where
    Self: Sized,
{
    fn from_config(props: FetchedProperties) -> Result<Self, StorageError>;
}

impl FromConfigProps<IcsItem> for CalDavDefinition<HttpsConnector<HttpConnector>> {
    fn from_config(props: FetchedProperties) -> Result<Self, StorageError> {
        let mut url = None;
        let mut username = None;
        let mut password = None;
        let mut props = props.iter();
        while let Some((key, value)) = props.next().transpose()? {
            match key {
                "type" => {}
                "start_date" => todo!(),
                "end_date" => todo!(),
                "item_types" => todo!(),
                "url" => url = Some(value.as_ref().parse().map_err(StorageError::InvalidUrl)?),
                "username" => username = Some(value.to_string()),
                "password" => password = Some(value.to_string().into()),
                "verify" => todo!(),
                "auth" => todo!(),
                "useragent" => todo!(),
                "verify_fingerprint" => todo!(),
                "auth_cert" => todo!(),
                s => return Err(StorageError::UnknownArgument(s.to_string())),
            }
        }

        let connector = HttpsConnectorBuilder::new()
            .with_native_roots()
            .https_or_http()
            .enable_http1()
            .build();

        Ok(CalDavDefinition {
            url: url.ok_or(StorageError::MissingField("url"))?,
            auth: Auth::Basic {
                username: username.ok_or(StorageError::MissingField("username"))?,
                password,
            },
            connector,
        })
    }
}

impl FromConfigProps<VcardItem> for CardDavDefinition<HttpsConnector<HttpConnector>> {
    // TODO: this is almost identical to the CalDavDefinition impl.
    //       can I somehow dedupe it?
    fn from_config(props: FetchedProperties) -> Result<Self, StorageError> {
        let mut url = None;
        let mut username = None;
        let mut password = None;
        let mut props = props.iter();
        while let Some((key, value)) = props.next().transpose()? {
            match key {
                "type" => {}
                "start_date" => todo!(),
                "end_date" => todo!(),
                "item_types" => todo!(),
                "url" => url = Some(value.as_ref().parse().map_err(StorageError::InvalidUrl)?),
                "username" => username = Some(value.to_string()),
                "password" => password = Some(value.to_string().into()),
                "verify" => todo!(),
                "auth" => todo!(),
                "useragent" => todo!(),
                "verify_fingerprint" => todo!(),
                "auth_cert" => todo!(),
                s => return Err(StorageError::UnknownArgument(s.to_string())),
            }
        }

        let connector = HttpsConnectorBuilder::new()
            .with_native_roots()
            .https_or_http()
            .enable_http1()
            .build();

        Ok(CardDavDefinition {
            url: url.ok_or(StorageError::MissingField("url"))?,
            auth: Auth::Basic {
                username: username.ok_or(StorageError::MissingField("username"))?,
                password,
            },
            connector,
        })
    }
}

impl<I: Item + 'static> FromConfigProps<I> for FilesystemDefinition<I>
where
    I::CollectionProperty: PropertyWithFilename,
{
    fn from_config(props: FetchedProperties) -> Result<Self, StorageError> {
        let mut path = None;
        let mut fileext = None;
        let mut props = props.iter();
        while let Some((key, value)) = props.next().transpose()? {
            match key {
                "type" => {}
                "path" => path = Some(PathBuf::from(value.as_ref())),
                "fileext" => fileext = Some(value.to_string()),
                "encoding" => todo!(),
                "post_hook" => todo!(),
                "fileignoreext" => todo!(),
                s => return Err(StorageError::UnknownArgument(s.to_string())),
            }
        }
        Ok(FilesystemDefinition::new(
            path.ok_or(StorageError::MissingField("path"))?,
            fileext.ok_or(StorageError::MissingField("fileext"))?,
        ))
    }
}

impl FromConfigProps<IcsItem> for WebCalDefinition {
    fn from_config(props: FetchedProperties) -> Result<Self, StorageError> {
        let mut url = None;
        let mut props = props.iter();
        while let Some((key, value)) = props.next().transpose()? {
            match key {
                "type" => {}
                "url" => url = Some(value.as_ref().parse().map_err(StorageError::InvalidUrl)?),
                "username" => todo!(),
                "password" => todo!(),
                "verify" => todo!(),
                "verify_fingerprint" => todo!(),
                "auth" => todo!(),
                "auth_cert" => todo!(),
                "useragent" => todo!(),
                s => return Err(StorageError::UnknownArgument(s.to_string())),
            }
        }
        Ok(WebCalDefinition {
            url: url.ok_or(StorageError::MissingField("url"))?,
            collection_name: "TODO".parse().unwrap(),
        })
    }
}

#[derive(Default, Debug)]
pub struct Config {
    status_path: PathBuf,
    pairs: HashMap<String, Pair>,
    storages: HashMap<String, Storage>,
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("Error reading ini file: {0}")]
    IniError(#[from] ini::Error),

    #[error("Missing name for {0} section")]
    SectionMissingName(&'static str),

    #[error("Error parsing configuration for pair {0}: {1}")]
    Pair(String, PairError),

    #[error("Error parsing configuration for storage {0}: {1}")]
    Storage(String, StorageError),

    #[error("Unknown section: {0}")]
    UnknownSection(String),

    #[error("Could not locate $XDG_CONFIG_HOME nor the current HOME directory")]
    CannotFindConfigDir,
}

/// Return the defult path for the configuration file.
pub fn default_configuration_path() -> Result<PathBuf, ConfigError> {
    // std::env::home_dir is deprecated on windows. That's irrelevant here.
    #[allow(deprecated)]
    let mut dir = if let Some(d) = std::env::var_os("$XDG_CONFIG_HOME") {
        PathBuf::from(d)
    } else if let Some(mut home) = std::env::home_dir() {
        home.push(".config");
        home
    } else {
        return Err(ConfigError::CannotFindConfigDir);
    };

    dir.push("vdirsyncer/config");
    Ok(dir)
}

pub fn read_from_default_path() -> Result<Config, ConfigError> {
    let path = default_configuration_path()?;
    let mut config = Config::default();

    let ini = Ini::load_from_file(path)?;
    for section in ini.sections() {
        let section = match section {
            Some(s) => s,
            // TODO: why am I getting the None section???
            None => continue,
        };

        let mut parts = section.split(' ');
        let kind = parts.next().expect("split always yields at list one item");
        let props = ini
            .section(Some(section))
            .expect("section found via iterator must exist");

        match kind {
            "general" => {
                config.status_path = parse_status_path(props);
            }
            "pair" => {
                let name = parts
                    .next()
                    .ok_or(ConfigError::SectionMissingName("pair"))?;
                let pair =
                    Pair::parse(props).map_err(|e| ConfigError::Pair(name.to_string(), e))?;
                config.pairs.insert(name.to_string(), pair);
            }
            "storage" => {
                let name = parts
                    .next()
                    .ok_or(ConfigError::SectionMissingName("storage"))?;
                let fetched_props = FetchedProperties::from(props);
                let storage = Storage::parse(fetched_props)
                    .map_err(|e| ConfigError::Storage(name.to_string(), e))?;
                config.storages.insert(name.to_string(), storage);
            }
            s => return Err(ConfigError::UnknownSection(s.to_string())),
        }
    }

    Ok(config)
}

fn parse_status_path(props: &ini::Properties) -> PathBuf {
    // TODO: should actually fail if any unknown keys are present.
    props.get("status_path").unwrap().into()
}

A vdirsyncer/src/config/fetch.rs => vdirsyncer/src/config/fetch.rs +79 -0
@@ 0,0 1,79 @@
use std::{
    borrow::Cow,
    io::Read,
    process::{Command, Stdio},
};

use ini::Properties;

#[derive(Debug, thiserror::Error)]
pub enum FetchError {
    #[error("Fetch source must be one of 'command', 'shell' or 'prompt'")]
    UnknownFetchSource,
}

/// Wrapper around properties that transparently handles fetch arguments.
///
/// See: <https://vdirsyncer.pimutils.org/en/stable/keyring.html>
pub(crate) struct FetchedProperties<'a>(&'a Properties);

impl<'a> From<&'a Properties> for FetchedProperties<'a> {
    fn from(props: &'a Properties) -> Self {
        FetchedProperties(props)
    }
}

impl<'a> FetchedProperties<'a> {
    pub(crate) fn iter(
        &self,
    ) -> impl DoubleEndedIterator<Item = Result<(&str, Cow<str>), FetchError>> {
        self.0.iter().map(|(k, v)| expand_pair_if_applicable(k, v))
    }

    pub fn get(&'a self, key: &'a str) -> Result<Option<Cow<'a, str>>, FetchError> {
        Ok(match self.0.get(key) {
            Some(val) => Some(expand_pair_if_applicable(key, val)?.1),
            None => None,
        })
    }
}

fn expand_fetch_value(val: &str) -> Result<Cow<str>, FetchError> {
    let args = serde_json::from_str::<Vec<String>>(val).unwrap();
    let mut args = args.iter();

    let fetch_type = args.next().unwrap();
    match fetch_type.as_str() {
        "command" => {
            // TODO: assert args.len > 0
            let mut child = Command::new(args.next().unwrap())
                .args(args)
                .stdout(Stdio::piped())
                .spawn()
                .unwrap();

            let mut stdout = child
                .stdout
                .take()
                .expect("stdout must be available when piping");
            let mut output = String::new();
            stdout.read_to_string(&mut output).unwrap();

            Ok(Cow::from(output))
        }
        "shell" => todo!("fetch source 'shell' is not implemented"),
        "prompt" => todo!("fetch source 'prompt' is not implemented"),
        _ => Ok(Cow::from(val)),
    }
}

fn expand_pair_if_applicable<'a>(
    key: &'a str,
    val: &'a str,
) -> Result<(&'a str, Cow<'a, str>), FetchError> {
    if let Some(key) = key.strip_suffix(".fetch") {
        Ok((key, expand_fetch_value(val)?))
    } else {
        Ok((key, Cow::from(val)))
    }
}

M vdirsyncer/src/main.rs => vdirsyncer/src/main.rs +20 -1
@@ 2,6 2,25 @@
//
// SPDX-License-Identifier: EUPL-1.2

// TODO: final version would have HashMap<String, Mutex<Storage>>
//       so that each storage can only by synced with one pair at once.

mod config;

fn main() {
    todo!();
    let config = config::read_from_default_path().unwrap();
    dbg!(config);
    // TODO: read config for storage_left
    // TODO: read config for storage_right

    // TODO: read cache_left (can be none)
    // TODO: read cache_right (can be none)

    // TODO: create sync pair

    // TODO: plan
    // TODO: dbg!(plan)

    // TODO: sync
    todo!("reached end of main");
}