WebCal is calcalist's own (src/webcal.rs), with Calendar::split_by_uid to turn one feed document into one file per UID. Shared crate::http and crate::secret came out of google/ so every leg uses them. CalDAV follows, behind a per-endpoint 'native' flag so it can run beside pimsync one collection at a time: src/caldav/client.rs speaks PROPFIND, sync-collection, calendar-multiget and conditional PUT/DELETE, and src/caldav/mod.rs is the pull/push pair. Radicale answers an initial sync-collection with no members, so a cursor is only stored alongside a complete read. doctor reports what a server advertises. skip_past (src/horizon.rs) takes a date, not a flag: a rolling 'before now' would retire events from the aggregate as they aged, deleting yesterday's events on every cycle. Pinned to a date, what is mirrored stays mirrored. On the real configuration it leaves 2600 of 3282 events behind. The filter sits above the prior-links lookup in mirror_source, which is what makes it safe: from there DeleteSource is unreachable, so excluding an event can never be read as the user having deleted its mirror. pimsync: CWD pinned so vstorage's relative unlink cannot reach the user's files; the dry-run workspace is removed by Drop rather than by a line every '?' stepped over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Rust
346 lines
11 KiB
Rust
//! 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<BTreeMap<String, Item>, 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<Item, VdirError> {
|
|
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<PathBuf, VdirError> {
|
|
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.
|
|
pub(crate) 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()
|
|
);
|
|
}
|
|
}
|