//! Driving the `pimsync` binary. //! //! pimsync handles the CalDAV and WebCal legs, mirroring each remote into a local //! vdir. It is invoked one-shot: its own daemon would race the reconciler over the //! same files, so scheduling belongs to calcalist. use std::fmt; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::process::Command; use thiserror::Error; use crate::config::{Config, Endpoint, EndpointKind}; pub const BINARY: &str = "pimsync"; /// The `0.5.x` series this build generates configuration for. pimsync is /// pre-1.0 and its config syntax may change between minor releases, so the /// range is deliberately narrow. pub const SUPPORTED_MAJOR: u64 = 0; pub const SUPPORTED_MINOR: u64 = 5; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Version { pub major: u64, pub minor: u64, pub patch: u64, } impl fmt::Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } impl Version { pub fn is_supported(&self) -> bool { self.major == SUPPORTED_MAJOR && self.minor == SUPPORTED_MINOR } /// Parses the `pimsync 0.5.11` line printed by `pimsync version`. fn parse(output: &str) -> Option { let field = output.split_whitespace().nth(1)?; let mut parts = field.split('.'); Some(Version { major: parts.next()?.parse().ok()?, minor: parts.next()?.parse().ok()?, patch: parts.next().unwrap_or("0").parse().ok()?, }) } } #[derive(Debug, Error)] pub enum PimsyncError { #[error("`{BINARY}` was not found on PATH; install it (devbox provides {BINARY}@0.5.11)")] NotFound, #[error("could not run `{BINARY}`: {0}")] Spawn(#[source] io::Error), #[error("`{BINARY} version` failed: {0}")] Failed(String), #[error("could not read a version out of `{0}`")] Unparseable(String), #[error("could not write {path}: {source}")] Write { path: std::path::PathBuf, #[source] source: io::Error, }, #[error("`{BINARY} {command}` failed: {message}")] Command { command: String, message: String }, #[error( "endpoint `{endpoint}` has url `{url}`, which names no calendar collection; it must point at the calendar itself, not at the server root" )] UrlHasNoCollection { endpoint: String, url: String }, } /// Asks the installed pimsync for its version. pub fn probe() -> Result { let output = Command::new(BINARY) .arg("version") .output() .map_err(|error| { if error.kind() == io::ErrorKind::NotFound { PimsyncError::NotFound } else { PimsyncError::Spawn(error) } })?; if !output.status.success() { return Err(PimsyncError::Failed( String::from_utf8_lossy(&output.stderr).trim().to_string(), )); } let text = String::from_utf8_lossy(&output.stdout); let line = text.lines().next().unwrap_or_default().trim(); Version::parse(line).ok_or_else(|| PimsyncError::Unparseable(line.to_string())) } /// Name of the single local storage every pair mirrors into. pimsync allows one /// storage to take part in several pairs, so one declaration serves all of them. const LOCAL_STORAGE: &str = "local"; pub fn config_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync.conf") } fn status_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync-status") } /// Builds a pimsync configuration covering every CalDAV and WebCal endpoint. /// /// Google endpoints are absent by design: pimsync has no REST storage, and its /// CalDAV storage speaks only HTTP Basic auth, which Google's endpoint has /// rejected since March 2025. calcalist syncs those itself. pub fn generate(config: &Config, state_dir: &Path) -> Result { let mut out = String::new(); out.push_str("# Generated by calcalist. Edits here are overwritten on every sync.\n\n"); out.push_str(&format!( "status_path {}\n\n", quote(&status_path(state_dir).display().to_string()) )); out.push_str(&format!( "storage {LOCAL_STORAGE} {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n", quote(&crate::sync::vdir_root(state_dir).display().to_string()) )); for endpoint in &config.endpoints { match &endpoint.kind { EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint)?), EndpointKind::Webcal { url, url_command } => { out.push_str(&webcal_pair( endpoint, url.as_deref(), url_command.as_deref(), )); } // Handled by calcalist's own Google module. EndpointKind::Google { .. } => {} } } Ok(out) } fn caldav_pair(endpoint: &Endpoint) -> Result { let EndpointKind::Caldav { url, username, secret_command, } = &endpoint.kind else { unreachable!("caller matched on Caldav") }; let (base, href) = split_collection_url(url).ok_or_else(|| PimsyncError::UrlHasNoCollection { endpoint: endpoint.id.clone(), url: url.clone(), })?; let id = &endpoint.id; let mut block = format!( "\nstorage {id}_remote {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\tusername {}\n", quote(&base), quote(username) ); if let Some(command) = secret_command { // Secrets never live in the portable config, so pimsync runs the same // command calcalist would to fetch them. block.push_str(&format!("\tpassword {{\n\t\tshell {command}\n\t}}\n")); } block.push_str("}\n"); // The remote is storage_a, so a concurrent server-side change wins over a // local one calcalist has not yet reconciled. Nothing is lost: the next // cycle sees the server's version and decides properly. block.push_str(&format!( "\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\thref_a {}\n\t\tid_b {id}\n\t}}\n\ \tconflict_resolution keep a\n\ton_empty skip\n\ton_delete skip\n}}\n", quote(&href) )); Ok(block) } fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String { let id = &endpoint.id; // A feed URL that is itself a credential is fetched by command, the same way // passwords are, so it never appears in either configuration file. let url_directive = match (url, url_command) { (_, Some(command)) => format!("\turl {{\n\t\tshell {command}\n\t}}\n"), (Some(url), None) => format!("\turl {}\n", quote(url)), (None, None) => String::new(), }; // No `read_only` directive here: pimsync 0.5.11 documents it as applying to // every storage type, but rejects it on a webcal storage with a bare "Could // not parse file". It is redundant regardless — webcal is read-only by type, // and `one_way` already fixes the direction. format!( "\nstorage {id}_remote {{\n\ttype webcal\n{url_directive}\tcollection_id {id}\n}}\n\ \npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\tid_a {id}\n\t\tid_b {id}\n\t}}\n\ \tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n" ) } /// Splits a calendar URL into the container to enumerate and the collection's /// own path. /// /// pimsync identifies a collection either by an id taken from a URL segment or /// by its exact path. The path is used here, so a calendar whose last segment /// happens to match another's cannot be confused for it. fn split_collection_url(url: &str) -> Option<(String, String)> { let authority_start = url.find("://")? + 3; let path_start = authority_start + url[authority_start..].find('/')?; let segments: Vec<&str> = url[path_start..] .split('/') .filter(|segment| !segment.is_empty()) .collect(); let (_, parents) = segments.split_last()?; let href = format!("/{}/", segments.join("/")); let base = match parents { [] => format!("{}/", &url[..path_start]), parents => format!("{}/{}/", &url[..path_start], parents.join("/")), }; Some((base, href)) } /// pimsync accepts quoted values, which keeps paths with spaces intact. fn quote(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } /// Writes the generated configuration, returning its path. pub fn write_config(config: &Config, state_dir: &Path) -> Result { let path = config_path(state_dir); let text = generate(config, state_dir)?; fs::create_dir_all(status_path(state_dir)).map_err(|source| PimsyncError::Write { path: status_path(state_dir), source, })?; fs::write(&path, text).map_err(|source| PimsyncError::Write { path: path.clone(), source, })?; Ok(path) } /// Runs one pimsync cycle. /// /// Deliberately one-shot: `pimsync daemon` would write the same vdirs the /// reconciler is reading, so calcalist owns the scheduling instead. pub fn sync(config_path: &Path) -> Result<(), PimsyncError> { run(config_path, "sync") } /// Validates the generated configuration and the storages it names. pub fn check(config_path: &Path) -> Result<(), PimsyncError> { run(config_path, "check") } fn run(config_path: &Path, command: &str) -> Result<(), PimsyncError> { let output = Command::new(BINARY) .arg("-c") .arg(config_path) .arg(command) .output() .map_err(|error| { if error.kind() == io::ErrorKind::NotFound { PimsyncError::NotFound } else { PimsyncError::Spawn(error) } })?; if output.status.success() { return Ok(()); } let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); Err(PimsyncError::Command { command: command.to_string(), message: if message.is_empty() { format!("exited with {}", output.status) } else { message }, }) } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use std::path::Path; const CONFIG: &str = r#" version = 1 [[endpoint]] id = "work" type = "caldav" url = "https://dav.example.com/dav/calendars/user/me/work/" username = "me@example.com" secret_command = "pass show example/caldav" [[endpoint]] id = "gcal" type = "google" calendar_id = "me@example.com" client_id = "abc.apps.googleusercontent.com" [[endpoint]] id = "holidays" type = "webcal" url = "https://example.org/holidays.ics" "#; /// The text of one `storage`/`pair` block, so assertions do not accidentally /// match a directive belonging to a different block. fn block<'a>(text: &'a str, opening: &str) -> &'a str { let start = text.find(opening).expect("block should be present"); let rest = &text[start..]; rest.find("\n}\n").map_or(rest, |end| &rest[..end]) } fn generated() -> String { let config: Config = toml::from_str(CONFIG).expect("config should parse"); generate(&config, Path::new("/var/state/calcalist")).expect("generation should succeed") } #[test] fn declares_one_local_storage_for_every_pair() { let text = generated(); assert_eq!(text.matches("storage local {").count(), 1); assert!(text.contains("path \"/var/state/calcalist/vdir\"")); assert!(text.contains("status_path \"/var/state/calcalist/pimsync-status\"")); } /// pimsync cannot authenticate to Google at all, so it must not be asked to. #[test] fn google_endpoints_are_left_out_entirely() { let text = generated(); assert!(!text.contains("gcal"), "google endpoint leaked into {text}"); } #[test] fn a_caldav_endpoint_becomes_a_bidirectional_pair() { let text = generated(); assert!(text.contains("storage work_remote {")); assert!(text.contains("type caldav")); // The container is enumerated, and the calendar picked out by exact path. assert!(text.contains("url \"https://dav.example.com/dav/calendars/user/me/\"")); assert!(text.contains("href_a \"/dav/calendars/user/me/work/\"")); assert!(text.contains("id_b work")); assert!( !block(&text, "pair work {").contains("one_way"), "caldav pairs must stay bidirectional" ); } /// Secrets stay out of the portable config; pimsync fetches them the same way. #[test] fn secrets_are_fetched_by_command_not_embedded() { let text = generated(); assert!(text.contains("password {")); assert!(text.contains("shell pass show example/caldav")); } #[test] fn a_feed_becomes_a_one_way_read_only_pair() { let text = generated(); assert!(text.contains("type webcal")); assert!(text.contains("one_way")); // pimsync 0.5.11 rejects `read_only` on a webcal storage despite // documenting it for all storage types. assert!(!block(&text, "storage holidays_remote {").contains("read_only")); assert!(text.contains("collection_id holidays")); } /// Both guards against a failed pull being mirrored as a mass deletion. #[test] fn every_pair_carries_the_deletion_guards() { let text = generated(); let pairs = text.matches("\npair ").count(); assert_eq!(pairs, 2); assert_eq!(text.matches("on_empty skip").count(), pairs); assert_eq!(text.matches("on_delete skip").count(), pairs); } /// A feed whose URL is itself a credential — Google's secret iCal address — /// must be fetched by command, never written into either config file. #[test] fn a_secret_feed_url_is_fetched_by_command() { let config: Config = toml::from_str( r#" version = 1 [[endpoint]] id = "gcal-feed" type = "webcal" url_command = "secret-tool lookup service calcalist account gcal-ics" "#, ) .expect("config should parse"); let text = generate(&config, Path::new("/var/state/calcalist")).expect("generate"); assert!(text.contains("url {")); assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics")); assert!(!text.contains("https://"), "no literal URL should appear"); } #[test] fn splits_a_calendar_url_into_container_and_path() { assert_eq!( split_collection_url("https://dav.example.com/dav/calendars/user/me/work/"), Some(( "https://dav.example.com/dav/calendars/user/me/".into(), "/dav/calendars/user/me/work/".into() )) ); // A trailing slash is optional. assert_eq!( split_collection_url("https://dav.example.com/calendars/work"), Some(( "https://dav.example.com/calendars/".into(), "/calendars/work/".into() )) ); // A single segment leaves the root as the container. assert_eq!( split_collection_url("https://dav.example.com/work/"), Some(("https://dav.example.com/".into(), "/work/".into())) ); } /// A bare server root names no calendar, so it must be rejected rather than /// guessed at. #[test] fn a_url_without_a_collection_is_refused() { assert_eq!(split_collection_url("https://dav.example.com/"), None); assert_eq!(split_collection_url("https://dav.example.com"), None); } #[test] fn parses_the_version_line() { assert_eq!( Version::parse("pimsync 0.5.11"), Some(Version { major: 0, minor: 5, patch: 11 }) ); } #[test] fn tolerates_a_two_component_version() { assert_eq!( Version::parse("pimsync 1.2"), Some(Version { major: 1, minor: 2, patch: 0 }) ); } #[test] fn rejects_unparseable_output() { assert_eq!(Version::parse("pimsync"), None); assert_eq!(Version::parse("pimsync unreleased"), None); } #[test] fn only_the_supported_series_is_accepted() { let supported = Version { major: 0, minor: 5, patch: 0, }; let next_series = Version { major: 0, minor: 6, patch: 0, }; assert!(supported.is_supported()); assert!(!next_series.is_supported()); } }