CalCalist/src/vdir.rs
randogoth 622a71dcd6 Sync WebCal and CalDAV directly; add a skip_past cutoff
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>
2026-09-13 17:41:32 +03:00

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()
);
}
}