Skip to main content

sioul_core/
vdir.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Address books and calendars on disk, as vdir: one folder per collection,
5//! one file per contact (`.vcf`) or per event (`.ics`), the collection's name
6//! and colour in the files `displayname` and `color`. pimsync, vdirsyncer,
7//! khal and khard read the same folders
8//! (<https://vdirsyncer.pimutils.org/en/stable/vdir.html>).
9//!
10//! What sync needs to remember, where each file sits on the server and its
11//! ETag, lives apart in the state folder: the data folders hold only your
12//! contacts and events.
13
14use crate::config::{data_dir, state_dir};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Kind {
21    Contacts,
22    Calendars,
23}
24
25impl Kind {
26    /// `~/.local/share/sioul/contacts`, `…/calendars`.
27    pub fn root(self) -> PathBuf {
28        data_dir().join(self.folder())
29    }
30
31    fn folder(self) -> &'static str {
32        match self {
33            Kind::Contacts => "contacts",
34            Kind::Calendars => "calendars",
35        }
36    }
37
38    /// The extension of its items.
39    pub fn extension(self) -> &'static str {
40        match self {
41            Kind::Contacts => "vcf",
42            Kind::Calendars => "ics",
43        }
44    }
45
46    /// The media type a server expects for its items.
47    pub fn media_type(self) -> &'static str {
48        match self {
49            Kind::Contacts => "text/vcard; charset=utf-8",
50            Kind::Calendars => "text/calendar; charset=utf-8",
51        }
52    }
53}
54
55/// One address book or calendar.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Collection {
58    pub kind: Kind,
59    pub account: String,
60    /// Its folder's name, made from the server's address of it.
61    pub id: String,
62    pub dir: PathBuf,
63    pub name: String,
64    /// "#4c6b5c", when the server gives one.
65    pub color: Option<String>,
66    /// The server lets you read it only.
67    pub read_only: bool,
68    /// For calendars: what it may hold ("VEVENT", "VTODO"); empty when the server does not say.
69    pub components: Vec<String>,
70}
71
72impl Collection {
73    /// Where it lives, in a word: "Google", its server's domain ("example.org");
74    /// "" for one kept on this computer only.
75    pub fn place(&self, config: &crate::config::Config) -> String {
76        if self.account == LOCAL {
77            return String::new();
78        }
79        let account = config.account(&self.account);
80        if matches!(crate::capabilities::provider_of_collection(account, self), crate::capabilities::Provider::Google | crate::capabilities::Provider::GoogleTasks) {
81            return "Google".into();
82        }
83        let address = account.and_then(|a| a.url.clone().or_else(|| a.host.clone())).unwrap_or_default();
84        let host = crate::sites::host_of(&if address.contains("://") { address } else { format!("https://{address}") });
85        let domain = crate::sites::domain_of(&host);
86        if domain.is_empty() { self.account.clone() } else { domain }
87    }
88
89    /// Its name and where it lives: "Agenda · example.org", "Maison · on this computer only".
90    pub fn label(&self, config: &crate::config::Config, tr: &crate::i18n::Translator) -> String {
91        let place = self.place(config);
92        format!("{} · {}", self.name, if place.is_empty() { tr.text("collection-here", None) } else { place })
93    }
94
95    /// Whether it takes items of this kind ("VEVENT", "VTODO"): when the server says nothing, it does.
96    pub fn holds(&self, component: &str) -> bool {
97        self.components.is_empty() || self.components.iter().any(|c| c.eq_ignore_ascii_case(component))
98    }
99}
100
101impl Collection {
102    /// Its contacts or events, by file.
103    pub fn items(&self) -> Vec<PathBuf> {
104        let Ok(entries) = std::fs::read_dir(&self.dir) else { return Vec::new() };
105        let mut items: Vec<PathBuf> = entries
106            .filter_map(Result::ok)
107            .map(|e| e.path())
108            .filter(|p| p.extension().is_some_and(|x| x == self.kind.extension()))
109            .collect();
110        items.sort();
111        items
112    }
113
114    /// Where sync keeps what it knows of it.
115    pub fn state_path(&self) -> PathBuf {
116        state_path(&self.account, self.kind, &self.id)
117    }
118}
119
120/// Where sync keeps what it knows of a collection.
121pub fn state_path(account: &str, kind: Kind, id: &str) -> PathBuf {
122    state_dir().join("dav").join(account).join(kind.folder()).join(format!("{id}.toml"))
123}
124
125/// Every collection of a kind, from every account, by account then name.
126pub fn collections(kind: Kind) -> Vec<Collection> {
127    every_collection(kind).into_iter().filter(|c| !State::load(&c.state_path()).deleted).collect()
128}
129
130/// The mark of an account switched off, in its folder of each kind.
131const OFF: &str = ".sioul-off";
132
133/// An account's calendars and address books out of the pages (`off`), or back.
134pub fn set_account_off(account: &str, off: bool) {
135    for kind in [Kind::Contacts, Kind::Calendars] {
136        let dir = kind.root().join(account);
137        if off {
138            if dir.is_dir() {
139                let _ = std::fs::write(dir.join(OFF), "Switched off in Sioul's Accounts: these collections wait here.\n");
140            }
141        } else {
142            let _ = std::fs::remove_file(dir.join(OFF));
143        }
144    }
145}
146
147/// Every collection kept here, those deleted and not yet gone from the server included.
148pub fn every_collection(kind: Kind) -> Vec<Collection> {
149    let mut found = Vec::new();
150    let Ok(accounts) = std::fs::read_dir(kind.root()) else { return found };
151    // An account switched off keeps its folders, out of the pages (`set_account_off`).
152    for account in accounts.filter_map(Result::ok).filter(|e| e.path().is_dir() && !e.path().join(OFF).exists()) {
153        let account_id = account.file_name().to_string_lossy().to_string();
154        let Ok(dirs) = std::fs::read_dir(account.path()) else { continue };
155        for dir in dirs.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()) {
156            let id = dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
157            let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
158            let state = State::load(&state_path(&account_id, kind, &id));
159            found.push(Collection {
160                kind,
161                account: account_id.clone(),
162                name: read("displayname").unwrap_or_else(|| id.clone()),
163                color: read("color"),
164                read_only: state.read_only,
165                components: state.components.clone(),
166                id,
167                dir,
168            });
169        }
170    }
171    found.sort_by(|a, b| (&a.account, a.name.to_lowercase()).cmp(&(&b.account, b.name.to_lowercase())));
172    found
173}
174
175/// A collection renamed here; the next sync tells the server.
176pub fn rename_collection(collection: &Collection, name: &str) -> Result<(), String> {
177    let name = name.trim();
178    if name.is_empty() {
179        return Err(format!("{}: a name is needed", collection.name));
180    }
181    if collection.read_only {
182        return Err(format!("{}: read only", collection.name));
183    }
184    crate::filelock::replace(&collection.dir.join("displayname"), name.as_bytes()).map_err(|e| e.to_string())?;
185    let path = collection.state_path();
186    let mut state = State::load(&path);
187    if collection.account != LOCAL && !state.pending {
188        state.renamed = true;
189        state.save(&path)?;
190    }
191    Ok(())
192}
193
194/// The items a collection holds here: events, tasks or contacts.
195pub fn holds_items(collection: &Collection) -> bool {
196    std::fs::read_dir(&collection.dir).into_iter().flatten().filter_map(Result::ok).any(|e| {
197        let name = e.file_name().to_string_lossy().to_lowercase();
198        name.ends_with(".ics") || name.ends_with(".vcf")
199    })
200}
201
202/// An empty collection deleted: at once when it was never sent, else hidden
203/// until the next sync deletes it on the server. One holding anything stays.
204pub fn delete_collection(collection: &Collection) -> Result<(), String> {
205    if holds_items(collection) {
206        return Err(format!("{}: not empty", collection.name));
207    }
208    if collection.read_only {
209        return Err(format!("{}: read only", collection.name));
210    }
211    let path = collection.state_path();
212    let mut state = State::load(&path);
213    if collection.account == LOCAL || state.pending {
214        let _ = std::fs::remove_dir_all(&collection.dir);
215        let _ = std::fs::remove_file(&path);
216        return Ok(());
217    }
218    state.deleted = true;
219    state.save(&path)
220}
221
222/// Whether a name (an account's, a collection's) is one folder of its own:
223/// not ".", "..", nor a path ("../x", "C:"), which would put files elsewhere.
224fn one_folder(name: &str) -> bool {
225    !matches!(name, "" | "." | "..") && !name.contains(['/', '\\', '\0']) && !(cfg!(windows) && name.contains(':'))
226}
227
228/// A collection's folder, made if needed, with its name and colour written for other tools.
229pub fn prepare(kind: Kind, account: &str, id: &str, name: &str, color: Option<&str>) -> std::io::Result<PathBuf> {
230    if !one_folder(account) || !one_folder(id) {
231        return Err(std::io::Error::other(format!("{account}/{id}: not a folder's name")));
232    }
233    let dir = kind.root().join(account).join(id);
234    std::fs::create_dir_all(&dir)?;
235    // Written beside, then renamed: the sharing (local lists) never reads them empty.
236    crate::filelock::replace(&dir.join("displayname"), name.as_bytes())?;
237    match color {
238        Some(color) => crate::filelock::replace(&dir.join("color"), color.as_bytes())?,
239        None => {
240            let _ = std::fs::remove_file(dir.join("color"));
241        }
242    }
243    Ok(dir)
244}
245
246/// The account of collections kept on this computer only.
247pub const LOCAL: &str = "local";
248
249/// A new collection made here: its folder, its name and colour, and a state
250/// that asks the next sync to create it on the server (MKCALENDAR or an
251/// extended MKCOL). `components` says what a calendar holds ("VTODO" for a
252/// task list). Returns it; an id already taken gets a number.
253pub fn create(kind: Kind, account: &str, name: &str, color: Option<&str>, components: &[&str]) -> Result<Collection, String> {
254    // The account comes from the window or an agent (the MCP): never a path.
255    if !one_folder(account) {
256        return Err(format!("{account}: not an account"));
257    }
258    let base = crate::config::slug(name);
259    let base = if base.is_empty() { "list".to_string() } else { base };
260    let taken = |id: &str| kind.root().join(account).join(id).exists() || state_path(account, kind, id).exists();
261    let id = std::iter::once(base.clone()).chain((2..100).map(|n| format!("{base}-{n}"))).find(|id| !taken(id)).ok_or_else(|| format!("{name}: no free name"))?;
262    let dir = prepare(kind, account, &id, name, color).map_err(|e| e.to_string())?;
263    // "local": kept here only, no server to create it on.
264    let state = State { pending: account != LOCAL, components: components.iter().map(|c| c.to_string()).collect(), ..State::default() };
265    state.save(&state_path(account, kind, &id))?;
266    Ok(Collection { kind, account: account.to_string(), id, dir, name: name.to_string(), color: color.map(str::to_string), read_only: false, components: state.components })
267}
268
269/// "personal", "contacts", "Calendrier%20perso" → a folder name that holds on
270/// every system: letters, digits, `-` and `_`, the rest as `_`.
271pub fn folder_id(href: &str) -> String {
272    let last = href.trim_end_matches('/').rsplit('/').next().unwrap_or("collection");
273    let id: String = last.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
274    if id.is_empty() { "collection".into() } else { id }
275}
276
277/// What sync knows of a collection: where it is, how far it got, and each item.
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279pub struct State {
280    /// The collection's address on the server.
281    #[serde(default)]
282    pub url: String,
283    /// RFC 6578: what the server gave at the last sync, to ask what changed since.
284    #[serde(default)]
285    pub sync_token: Option<String>,
286    /// The collection's own tag (getctag): unchanged, nothing changed.
287    #[serde(default)]
288    pub ctag: Option<String>,
289    #[serde(default)]
290    pub read_only: bool,
291    /// For calendars: what the server lets it hold ("VEVENT", "VTODO"); empty when it does not say.
292    #[serde(default)]
293    pub components: Vec<String>,
294    /// Made here: the next sync creates it on the server, then sends its items.
295    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
296    pub pending: bool,
297    /// Renamed here: the next sync gives the server its new name.
298    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299    pub renamed: bool,
300    /// Deleted here, empty: hidden, and the next sync deletes it on the
301    /// server, unless something was put in it there meanwhile.
302    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
303    pub deleted: bool,
304    #[serde(default, rename = "item")]
305    pub items: Vec<ItemState>,
306}
307
308/// One item, as last agreed with the server.
309#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
310pub struct ItemState {
311    /// Its address on the server, as the server writes it.
312    pub href: String,
313    /// Its file name in the collection's folder.
314    pub file: String,
315    pub etag: String,
316    /// A hash of the file as last synced: a different one means it was changed here.
317    pub hash: String,
318    /// The file's size and modification time (nanoseconds since 1970) when
319    /// last found as synced: both the same, it is unchanged, and not read.
320    /// A time of 0 is unknown: the file is read and its hash compared.
321    #[serde(default)]
322    pub size: u64,
323    #[serde(default)]
324    pub mtime: i64,
325}
326
327impl State {
328    pub fn load(path: &Path) -> State {
329        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
330    }
331
332    /// Written next to its place, then moved: an interruption never leaves half a state.
333    pub fn save(&self, path: &Path) -> Result<(), String> {
334        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
335        if let Some(parent) = path.parent() {
336            std::fs::create_dir_all(parent).map_err(fail)?;
337        }
338        let text = toml::to_string(self).map_err(|e| e.to_string())?;
339        let temporary = path.with_extension("toml.new");
340        std::fs::write(&temporary, text).map_err(fail)?;
341        std::fs::rename(&temporary, path).map_err(fail)
342    }
343}
344
345/// A short hash of a file's content, to tell whether it changed since sync.
346pub fn content_hash(bytes: &[u8]) -> String {
347    // FNV-1a, 64 bits: stable across versions of Rust, unlike `DefaultHasher`.
348    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
349    for byte in bytes {
350        hash ^= u64::from(*byte);
351        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
352    }
353    format!("{hash:016x}")
354}
355
356/// Writes an item next to its place, then moves it in.
357pub fn write_item(path: &Path, text: &str) -> Result<(), String> {
358    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
359    if let Some(parent) = path.parent() {
360        std::fs::create_dir_all(parent).map_err(fail)?;
361    }
362    let temporary = path.with_extension("tmp");
363    std::fs::write(&temporary, text).map_err(fail)?;
364    std::fs::rename(&temporary, path).map_err(fail)
365}
366
367/// A new item's name: unique, and safe as a file name and in a URL.
368pub fn new_name() -> String {
369    use std::hash::{Hash, Hasher};
370    // Two names asked in the same instant (an import; a clock that ticks by the
371    // microsecond, as on macOS) still differ: a file would be written over.
372    static MADE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
373    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
374    let mut hasher = std::collections::hash_map::DefaultHasher::new();
375    now.hash(&mut hasher);
376    std::process::id().hash(&mut hasher);
377    MADE.fetch_add(1, std::sync::atomic::Ordering::Relaxed).hash(&mut hasher);
378    format!("sioul-{:x}-{:016x}", now.as_secs(), hasher.finish())
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn folder_names_hold_everywhere() {
387        assert_eq!(folder_id("/remote.php/dav/calendars/jane/personal/"), "personal");
388        assert_eq!(folder_id("/dav/Calendrier%20perso/"), "Calendrier_20perso");
389        assert_eq!(folder_id("/"), "collection");
390        assert_eq!(content_hash(b"BEGIN:VCARD"), content_hash(b"BEGIN:VCARD"));
391        assert_ne!(content_hash(b"a"), content_hash(b"b"));
392        // An account or a collection is one folder, never a path out of the data folders.
393        assert!(one_folder("local") && one_folder("murmur-2") && !one_folder("..") && !one_folder("../x") && !one_folder("a\\b") && !one_folder(""));
394        assert!(create(Kind::Calendars, "../elsewhere", "List", None, &["VTODO"]).is_err());
395        let names: std::collections::BTreeSet<String> = (0..1000).map(|_| new_name()).collect();
396        assert_eq!(names.len(), 1000, "never the same name twice");
397    }
398}