//! Reading and writing vdir directories. //! //! A vdir is a flat directory of `.ics` files, one per item. It is the seam //! between calcalist and pimsync: pimsync mirrors each remote into one of these, //! and the reconciler then works purely on local files. //! //! Filenames are ours to choose, so items are always keyed by the UID inside the //! file rather than by its name — pimsync and other tools name files differently //! and we must read whatever they wrote. use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use thiserror::Error; use crate::ical::{Calendar, IcalError}; const EXTENSION: &str = "ics"; #[derive(Debug, Error)] pub enum VdirError { #[error("could not read directory {path}: {source}")] ReadDir { path: PathBuf, #[source] source: io::Error, }, #[error("could not read {path}: {source}")] ReadFile { path: PathBuf, #[source] source: io::Error, }, #[error("could not write {path}: {source}")] Write { path: PathBuf, #[source] source: io::Error, }, #[error("could not remove {path}: {source}")] Remove { path: PathBuf, #[source] source: io::Error, }, #[error("{path} is not valid iCalendar: {source}")] Malformed { path: PathBuf, #[source] source: IcalError, }, #[error("{path} contains no VEVENT UID")] NoUid { path: PathBuf }, } /// One item in a vdir, keyed by the UID found inside it. #[derive(Debug, Clone)] pub struct Item { pub uid: String, pub path: PathBuf, pub calendar: Calendar, /// Digest of the meaningful content, for change detection. pub hash: String, } /// Every item in `dir`, keyed by UID. /// /// A missing directory reads as empty: an endpoint that has never synced is not /// an error, and must not be mistaken for one whose events were all deleted. pub fn read(dir: &Path) -> Result, VdirError> { let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()), Err(source) => { return Err(VdirError::ReadDir { path: dir.to_path_buf(), source, }); } }; let mut items = BTreeMap::new(); for entry in entries { let path = entry .map_err(|source| VdirError::ReadDir { path: dir.to_path_buf(), source, })? .path(); if !is_calendar_file(&path) { continue; } let item = read_item(&path)?; items.insert(item.uid.clone(), item); } Ok(items) } fn read_item(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|source| VdirError::ReadFile { path: path.to_path_buf(), source, })?; let calendar = Calendar::parse(&text).map_err(|source| VdirError::Malformed { path: path.to_path_buf(), source, })?; let uid = calendar .uid() .ok_or_else(|| VdirError::NoUid { path: path.to_path_buf(), })? .to_string(); let hash = calendar.content_hash(); Ok(Item { uid, path: path.to_path_buf(), calendar, hash, }) } /// Writes `calendar` into `dir` under a name derived from its UID. /// /// `replaces` is the path the item currently occupies, when it has one. Filenames /// in a vdir are arbitrary — pimsync chooses its own — so an item already present /// under a different name must be removed, or the calendar would end up holding /// the same event twice. /// /// The write is atomic, so a crash cannot leave pimsync a half-written event to push. pub fn write( dir: &Path, uid: &str, calendar: &Calendar, replaces: Option<&Path>, ) -> Result { fs::create_dir_all(dir).map_err(|source| VdirError::Write { path: dir.to_path_buf(), source, })?; let path = dir.join(file_name(uid)); let temporary = path.with_extension("ics.tmp"); fs::write(&temporary, calendar.to_ics()).map_err(|source| VdirError::Write { path: temporary.clone(), source, })?; fs::rename(&temporary, &path).map_err(|source| VdirError::Write { path: path.clone(), source, })?; if let Some(previous) = replaces && previous != path { remove(previous)?; } Ok(path) } pub fn remove(path: &Path) -> Result<(), VdirError> { match fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), Err(source) => Err(VdirError::Remove { path: path.to_path_buf(), source, }), } } fn is_calendar_file(path: &Path) -> bool { path.is_file() && path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case(EXTENSION)) } /// A filesystem-safe, stable, collision-free name for a UID. /// /// UIDs may contain characters no filesystem accepts, and sanitising alone would /// let two different UIDs collide, so a digest of the original is appended. fn file_name(uid: &str) -> String { let sanitised: String = uid .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' } }) .take(100) .collect(); let digest: String = blake3::hash(uid.as_bytes()) .as_bytes() .iter() .take(4) .map(|byte| format!("{byte:02x}")) .collect(); format!("{sanitised}-{digest}.{EXTENSION}") } #[cfg(test)] mod tests { use super::*; fn event(uid: &str, summary: &str) -> Calendar { let text = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260910T090000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ); Calendar::parse(&text).expect("fixture should parse") } #[test] fn a_missing_directory_reads_as_empty_not_as_an_error() { let dir = tempfile::tempdir().expect("temp dir"); let missing = dir.path().join("never-synced"); assert!( read(&missing) .expect("missing dir is not an error") .is_empty() ); } #[test] fn writes_and_reads_back_by_uid() { let dir = tempfile::tempdir().expect("temp dir"); write( dir.path(), "event-1@example.com", &event("event-1@example.com", "Standup"), None, ) .expect("write"); let items = read(dir.path()).expect("read"); assert_eq!(items.len(), 1); let item = items.get("event-1@example.com").expect("keyed by uid"); assert_eq!( item.calendar.to_ics(), event("event-1@example.com", "Standup").to_ics() ); } /// Filenames are ours, but the key must come from the file's contents, since /// pimsync names files its own way. #[test] fn items_are_keyed_by_content_not_filename() { let dir = tempfile::tempdir().expect("temp dir"); let text = event("event-1@example.com", "Standup").to_ics(); fs::write(dir.path().join("some-name-pimsync-chose.ics"), text).expect("write"); let items = read(dir.path()).expect("read"); assert!(items.contains_key("event-1@example.com")); } #[test] fn rewriting_the_same_uid_replaces_rather_than_accumulates() { let dir = tempfile::tempdir().expect("temp dir"); let uid = "event-1@example.com"; write(dir.path(), uid, &event(uid, "First"), None).expect("write"); write(dir.path(), uid, &event(uid, "Second"), None).expect("rewrite"); let items = read(dir.path()).expect("read"); assert_eq!(items.len(), 1); assert!(items[uid].calendar.to_ics().contains("SUMMARY:Second")); } /// Regression: filenames in a vdir are arbitrary and pimsync picks its own, /// so writing an item already present under a different name must replace it /// rather than leave the calendar holding the same event twice. #[test] fn writing_replaces_an_item_stored_under_a_foreign_filename() { let dir = tempfile::tempdir().expect("temp dir"); let uid = "event-1@example.com"; let foreign = dir.path().join("pimsync-chose-this.ics"); fs::write(&foreign, event(uid, "Original").to_ics()).expect("write"); write(dir.path(), uid, &event(uid, "Edited"), Some(&foreign)).expect("write"); let items = read(dir.path()).expect("read"); assert_eq!(items.len(), 1, "the event must not appear twice"); assert!(items[uid].calendar.to_ics().contains("SUMMARY:Edited")); assert!(!foreign.exists(), "the superseded file should be gone"); } /// Two UIDs that sanitise identically must not overwrite each other. #[test] fn unsafe_uids_get_distinct_filenames() { let first = file_name("a/b:c"); let second = file_name("a_b_c"); assert_ne!(first, second); assert!(!first.contains('/')); assert!(first.ends_with(".ics")); } #[test] fn ignores_files_that_are_not_calendars() { let dir = tempfile::tempdir().expect("temp dir"); fs::write(dir.path().join("README.md"), "not a calendar").expect("write"); write(dir.path(), "event-1", &event("event-1", "Standup"), None).expect("write"); assert_eq!(read(dir.path()).expect("read").len(), 1); } #[test] fn removing_an_absent_file_is_not_an_error() { let dir = tempfile::tempdir().expect("temp dir"); remove(&dir.path().join("gone.ics")).expect("removing nothing should succeed"); } /// A server rewriting DTSTAMP must not look like a user edit. #[test] fn the_hash_ignores_volatile_properties() { let original = event("event-1", "Standup"); let restamped = Calendar::parse( &original .to_ics() .replace("DTSTAMP:20260910T090000Z", "DTSTAMP:20261225T235959Z"), ) .expect("parse"); assert_eq!(original.content_hash(), restamped.content_hash()); } #[test] fn the_hash_notices_real_edits() { assert_ne!( event("event-1", "Standup").content_hash(), event("event-1", "Retrospective").content_hash() ); } /// Property order carries no meaning in iCalendar, so it must not churn the hash. #[test] fn the_hash_ignores_property_order() { let reordered = Calendar::parse( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Standup\r\nDTSTAMP:20260910T090000Z\r\nUID:event-1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", ) .expect("parse"); assert_eq!( event("event-1", "Standup").content_hash(), reordered.content_hash() ); } }