@@ 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"
@@ 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()
+}
@@ 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)))
+ }
+}