CalCalist/src/vdir.rs

347 lines
11 KiB
Rust
Raw Normal View History

Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
//! 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.
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()
);
}
}