Skip to main content

vault_core/
calendar.rs

1//! iCalendar (RFC 5545) feed for the vault's dates — the Rust twin of
2//! `src/ts/calendar.ts`.
3//!
4//! This is the fourth format in the project implemented twice, and it follows
5//! the rule the other three established: **one golden file, asserted from both
6//! sides**. `tests/fixtures/parity/calendar.ics` is written by the TypeScript
7//! suite and read back by `unv-cli/tests/parity.rs`, because reviewing two
8//! implementations for agreement does not work — the first time that fixture
9//! ran for the exporters it found two live bugs.
10//!
11//! # What is deliberately not in the file
12//!
13//! No secret values, and no fingerprints either. An `.ics` is the least private
14//! thing this program writes: it exists to be handed to Google Calendar or a
15//! phone, which stores it unencrypted on hardware nobody here controls. A
16//! fingerprint is stable per value, so a feed carrying them would tell anyone
17//! holding two feeds which secrets are the same — an equality oracle over the
18//! vault, published to a third party. Names and dates travel; nothing else.
19
20use serde_json::Value;
21use time::OffsetDateTime;
22
23/// Which kinds of event to emit.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum EventKind {
26    Created,
27    Expires,
28    Rotation,
29}
30
31impl EventKind {
32    /// The `CATEGORIES` value, matching the TypeScript side's
33    /// `ev.kind.toUpperCase()`.
34    fn category(self) -> &'static str {
35        match self {
36            EventKind::Created => "CREATED",
37            EventKind::Expires => "EXPIRES",
38            EventKind::Rotation => "ROTATION",
39        }
40    }
41
42    /// Parses a CLI `--kind` value.
43    pub fn parse(s: &str) -> Option<Self> {
44        match s.to_ascii_lowercase().as_str() {
45            "created" | "creation" => Some(EventKind::Created),
46            "expires" | "expiry" | "expiring" => Some(EventKind::Expires),
47            "rotation" | "rotate" => Some(EventKind::Rotation),
48            _ => None,
49        }
50    }
51}
52
53/// Options for [`build_ics`].
54pub struct IcsOptions {
55    pub kinds: Vec<EventKind>,
56    /// `DTSTAMP` as an RFC 3339 string.
57    ///
58    /// Injectable for the same reason as the TypeScript side: a fixture that
59    /// embeds the wall clock cannot be compared with anything, and without a
60    /// fixed stamp the CLI and the app can never produce identical bytes.
61    pub now: String,
62    pub calendar_name: String,
63}
64
65impl Default for IcsOptions {
66    fn default() -> Self {
67        IcsOptions {
68            kinds: vec![EventKind::Created, EventKind::Expires, EventKind::Rotation],
69            now: crate::iso_now(),
70            calendar_name: "UnENVerse".to_string(),
71        }
72    }
73}
74
75struct CalEvent {
76    uid: String,
77    date: String,
78    summary: String,
79    description: String,
80    kind: EventKind,
81    alarm_days_before: u32,
82}
83
84/// RFC 5545 §3.3.11 escaping: backslash, semicolon, comma, newline.
85pub fn ics_escape(text: &str) -> String {
86    let mut out = String::with_capacity(text.len());
87    let mut chars = text.chars().peekable();
88    while let Some(c) = chars.next() {
89        match c {
90            '\\' => out.push_str("\\\\"),
91            ';' => out.push_str("\\;"),
92            ',' => out.push_str("\\,"),
93            '\r' => {
94                // CRLF collapses to one escaped newline, matching the
95                // TypeScript `/\r?\n/`.
96                if chars.peek() == Some(&'\n') {
97                    chars.next();
98                }
99                out.push_str("\\n");
100            }
101            '\n' => out.push_str("\\n"),
102            _ => out.push(c),
103        }
104    }
105    out
106}
107
108/// RFC 5545 §3.1 folding: 75 **octets** for the first line, 74 for each
109/// continuation, which begins with a space.
110///
111/// Octets, not characters — the same trap the TypeScript twin documents. A name
112/// with an accent or an emoji is several bytes per character, and folding by
113/// character count yields a line that is legal by one measure and illegal by the
114/// one a parser applies.
115pub fn ics_fold(line: &str) -> String {
116    let bytes = line.as_bytes();
117    if bytes.len() <= 75 {
118        return line.to_string();
119    }
120    let mut out = String::new();
121    let mut start = 0usize;
122    let mut limit = 75usize;
123    while start < bytes.len() {
124        let mut end = (start + limit).min(bytes.len());
125        // Never split a UTF-8 sequence: continuation bytes are 0b10xxxxxx.
126        while end > start && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
127            end -= 1;
128        }
129        if !out.is_empty() {
130            out.push_str("\r\n ");
131        }
132        out.push_str(&line[start..end]);
133        start = end;
134        limit = 74;
135    }
136    out
137}
138
139/// Parses an RFC 3339 timestamp, falling back to a bare `YYYY-MM-DD` prefix.
140///
141/// The fallback is not laxity for its own sake: vault JSON can come from an
142/// imported backup or a hand edit, and a date-only `expires_at` is exactly what
143/// a human types. `new Date()` on the TypeScript side accepts it, so refusing
144/// it here would put an event in the app's calendar and not in the CLI's.
145fn parse_dt(iso: &str) -> Option<OffsetDateTime> {
146    if let Ok(dt) = OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339) {
147        return Some(dt);
148    }
149    let head = iso.get(0..10)?;
150    let mut parts = head.split('-');
151    let y: i32 = parts.next()?.parse().ok()?;
152    let m: u8 = parts.next()?.parse().ok()?;
153    let d: u8 = parts.next()?.parse().ok()?;
154    let date = time::Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok()?;
155    Some(date.midnight().assume_utc())
156}
157
158/// `2026-08-26T21:00:00Z` → `20260826` (UTC), or `None` when unparseable.
159pub fn to_ics_date(iso: Option<&str>) -> Option<String> {
160    let dt = parse_dt(iso?)?;
161    Some(format!(
162        "{:04}{:02}{:02}",
163        dt.year(),
164        dt.month() as u8,
165        dt.day()
166    ))
167}
168
169/// `2026-08-26T21:00:00Z` → `20260826T210000Z`, for `DTSTAMP`.
170pub fn to_ics_stamp(iso: &str) -> String {
171    let dt = parse_dt(iso).unwrap_or(OffsetDateTime::UNIX_EPOCH);
172    let utc = dt.to_offset(time::UtcOffset::UTC);
173    format!(
174        "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
175        utc.year(),
176        utc.month() as u8,
177        utc.day(),
178        utc.hour(),
179        utc.minute(),
180        utc.second()
181    )
182}
183
184/// The day after `YYYYMMDD` — what an all-day `DTEND` must be.
185pub fn next_day(yyyymmdd: &str) -> String {
186    let y: i32 = yyyymmdd[0..4].parse().unwrap_or(1970);
187    let m: u8 = yyyymmdd[4..6].parse().unwrap_or(1);
188    let d: u8 = yyyymmdd[6..8].parse().unwrap_or(1);
189    let date = time::Month::try_from(m)
190        .ok()
191        .and_then(|mm| time::Date::from_calendar_date(y, mm, d).ok())
192        .map(|dd| dd.next_day().unwrap_or(dd));
193    match date {
194        Some(dd) => format!("{:04}{:02}{:02}", dd.year(), dd.month() as u8, dd.day()),
195        None => yyyymmdd.to_string(),
196    }
197}
198
199fn s(entry: &Value, key: &str) -> Option<String> {
200    entry
201        .get(key)
202        .and_then(|v| v.as_str())
203        .filter(|v| !v.is_empty())
204        .map(str::to_string)
205}
206
207/// Display name: provider, plus whatever distinguishes it from its siblings.
208pub fn entry_label(entry: &Value) -> String {
209    let provider = s(entry, "provider").unwrap_or_default();
210    let extra: Vec<String> = ["key_id", "account_name"]
211        .iter()
212        .filter_map(|k| s(entry, k))
213        .collect();
214    if extra.is_empty() {
215        provider
216    } else {
217        format!("{provider} ({})", extra.join(" / "))
218    }
219}
220
221/// When rotation is next due, anchored on the last rotation or, failing that,
222/// on creation.
223///
224/// A 90-day key that has never been rotated is due 90 days after it was issued,
225/// not never. An entry with neither anchor gets no event: putting a deadline in
226/// someone's calendar that no evidence supports is worse than leaving it out.
227pub fn rotation_due(entry: &Value) -> Option<String> {
228    let days = entry.get("rotation_days").and_then(|v| v.as_i64())?;
229    if days <= 0 {
230        return None;
231    }
232    let anchor = s(entry, "last_rotated_at").or_else(|| s(entry, "created_at"))?;
233    let dt = parse_dt(&anchor)?;
234    let due = dt + time::Duration::days(days);
235    Some(format!(
236        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
237        due.year(),
238        due.month() as u8,
239        due.day(),
240        due.hour(),
241        due.minute(),
242        due.second()
243    ))
244}
245
246fn meta_lines(entry: &Value) -> String {
247    let mut parts = Vec::new();
248    if let Some(t) = s(entry, "secretType") {
249        parts.push(format!("Type: {t}"));
250    }
251    if let Some(e) = s(entry, "environment") {
252        parts.push(format!("Environment: {e}"));
253    }
254    let cats: Vec<String> = entry
255        .get("categories")
256        .and_then(|v| v.as_array())
257        .map(|a| {
258            a.iter()
259                .filter_map(|c| c.as_str())
260                .map(str::to_string)
261                .collect()
262        })
263        .unwrap_or_default();
264    if !cats.is_empty() {
265        parts.push(format!("Categories: {}", cats.join(", ")));
266    }
267    parts.join("\n")
268}
269
270fn events_for_entry(entry: &Value, kinds: &[EventKind]) -> Vec<CalEvent> {
271    let mut out = Vec::new();
272    // `id` is guaranteed by the app's `ensureEntryIds`; the fallback stops a
273    // hand-edited vault producing two events with one UID, which a calendar
274    // treats as one event updating itself.
275    let id = s(entry, "id").unwrap_or_else(|| {
276        format!(
277            "{}:{}:{}",
278            s(entry, "provider").unwrap_or_default(),
279            s(entry, "key_id").unwrap_or_default(),
280            s(entry, "account_name").unwrap_or_default()
281        )
282    });
283    let label = entry_label(entry);
284    let meta = meta_lines(entry);
285    let join = |head: String| -> String {
286        if meta.is_empty() {
287            head
288        } else {
289            format!("{head}\n{meta}")
290        }
291    };
292
293    if kinds.contains(&EventKind::Created) {
294        if let Some(date) = to_ics_date(s(entry, "created_at").as_deref()) {
295            out.push(CalEvent {
296                uid: format!("{id}-created@unenverse"),
297                date,
298                summary: format!("Created: {label}"),
299                description: join(format!("{label} was added to the vault.")),
300                kind: EventKind::Created,
301                alarm_days_before: 0,
302            });
303        }
304    }
305
306    if kinds.contains(&EventKind::Expires) {
307        if let Some(date) = to_ics_date(s(entry, "expires_at").as_deref()) {
308            out.push(CalEvent {
309                uid: format!("{id}-expires@unenverse"),
310                date,
311                summary: format!("Expires: {label}"),
312                description: join(format!("{label} expires on this day.")),
313                kind: EventKind::Expires,
314                // A reminder on the morning it dies arrives during the outage.
315                alarm_days_before: 7,
316            });
317        }
318    }
319
320    // Dates that live in a type's named variables rather than `expires_at`
321    // (Phase 24.5): a licence's maintenance window and an identity document's
322    // expiry. Date only, never the number or the holder: an identity document
323    // gets no metadata lines at all, because those lines carry account names and
324    // a calendar feed is shared further than the vault is.
325    if kinds.contains(&EventKind::Expires) {
326        let (var, what, with_meta) = match s(entry, "secretType").as_deref() {
327            Some("license_key") => ("maintenance_until", "Maintenance ends", true),
328            Some("identity_document") => ("expires", "Expires", false),
329            _ => ("", "", false),
330        };
331        if !var.is_empty() {
332            let value = entry
333                .get("extra_vars")
334                .and_then(|v| v.as_array())
335                .into_iter()
336                .flatten()
337                .find(|v| v.get("key").and_then(|k| k.as_str()) == Some(var))
338                .and_then(|v| v.get("value").and_then(|x| x.as_str()))
339                .map(str::to_string);
340            if let Some(date) = to_ics_date(value.as_deref()) {
341                // `entry_label` appends the account name, which for an identity
342                // document is the holder's.
343                let label = if with_meta {
344                    label.clone()
345                } else {
346                    s(entry, "provider").unwrap_or_default()
347                };
348                let head = format!("{label}: {}.", what.to_lowercase());
349                out.push(CalEvent {
350                    uid: format!("{id}-{var}@unenverse"),
351                    date,
352                    summary: format!("{what}: {label}"),
353                    description: if with_meta { join(head) } else { head },
354                    kind: EventKind::Expires,
355                    alarm_days_before: 30,
356                });
357            }
358        }
359    }
360
361    if kinds.contains(&EventKind::Rotation) {
362        if let Some(due) = rotation_due(entry) {
363            if let Some(date) = to_ics_date(Some(&due)) {
364                let days = entry
365                    .get("rotation_days")
366                    .and_then(|v| v.as_i64())
367                    .unwrap_or(0);
368                out.push(CalEvent {
369                    uid: format!("{id}-rotation@unenverse"),
370                    date,
371                    summary: format!("Rotate: {label}"),
372                    description: join(format!("{label} is due for rotation (every {days} days).")),
373                    kind: EventKind::Rotation,
374                    alarm_days_before: 3,
375                });
376            }
377        }
378    }
379
380    out
381}
382
383/// Builds the calendar. Byte-identical to `buildIcs` in `src/ts/calendar.ts`
384/// for the same input — that is what the parity fixture checks.
385pub fn build_ics(entries: &[Value], opts: &IcsOptions) -> String {
386    let stamp = to_ics_stamp(&opts.now);
387
388    let mut events: Vec<CalEvent> = entries
389        .iter()
390        .flat_map(|e| events_for_entry(e, &opts.kinds))
391        .collect();
392    // Sorted by date then UID so two runs over one vault produce the same bytes;
393    // otherwise the file's order follows the entry array and a reordered vault
394    // looks like a changed calendar to anything diffing it.
395    events.sort_by(|a, b| a.date.cmp(&b.date).then_with(|| a.uid.cmp(&b.uid)));
396
397    let mut lines: Vec<String> = vec![
398        "BEGIN:VCALENDAR".into(),
399        "VERSION:2.0".into(),
400        "PRODID:-//EnvVault//Secrets Calendar//EN".into(),
401        "CALSCALE:GREGORIAN".into(),
402        "METHOD:PUBLISH".into(),
403        format!("X-WR-CALNAME:{}", ics_escape(&opts.calendar_name)),
404        "X-WR-TIMEZONE:UTC".into(),
405    ];
406
407    for ev in &events {
408        lines.push("BEGIN:VEVENT".into());
409        lines.push(format!("UID:{}", ics_escape(&ev.uid)));
410        lines.push(format!("DTSTAMP:{stamp}"));
411        lines.push(format!("DTSTART;VALUE=DATE:{}", ev.date));
412        lines.push(format!("DTEND;VALUE=DATE:{}", next_day(&ev.date)));
413        lines.push(format!("SUMMARY:{}", ics_escape(&ev.summary)));
414        lines.push(format!("DESCRIPTION:{}", ics_escape(&ev.description)));
415        lines.push(format!("CATEGORIES:{}", ev.kind.category()));
416        // Markers, not commitments: three expiries on one day must not show the
417        // owner as busy all day.
418        lines.push("TRANSP:TRANSPARENT".into());
419        if ev.alarm_days_before > 0 {
420            lines.push("BEGIN:VALARM".into());
421            lines.push("ACTION:DISPLAY".into());
422            lines.push(format!("DESCRIPTION:{}", ics_escape(&ev.summary)));
423            lines.push(format!("TRIGGER:-P{}D", ev.alarm_days_before));
424            lines.push("END:VALARM".into());
425        }
426        lines.push("END:VEVENT".into());
427    }
428    lines.push("END:VCALENDAR".into());
429
430    let folded: Vec<String> = lines.iter().map(|l| ics_fold(l)).collect();
431    // CRLF is not optional in RFC 5545, and the trailing one is what makes the
432    // last line a line.
433    format!("{}\r\n", folded.join("\r\n"))
434}
435
436/// Number of `VEVENT` blocks in a rendered calendar — for the CLI's summary
437/// line, which reports a count rather than printing the file.
438pub fn event_count(ics: &str) -> usize {
439    ics.matches("BEGIN:VEVENT").count()
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use serde_json::json;
446
447    #[test]
448    fn escapes_the_four_reserved_characters() {
449        assert_eq!(ics_escape("a,b;c\\d\ne"), "a\\,b\\;c\\\\d\\ne");
450    }
451
452    #[test]
453    fn folds_on_octets_not_characters() {
454        // Ninety é characters: 90 chars, 180 bytes. Folding by character count
455        // would leave the first line legal-looking and 180 octets long.
456        let line = "é".repeat(90);
457        let folded = ics_fold(&line);
458        for part in folded.split("\r\n") {
459            let content = part.strip_prefix(' ').unwrap_or(part);
460            assert!(
461                content.len() <= 75,
462                "folded segment is {} octets",
463                content.len()
464            );
465        }
466        // Folding must be reversible: unfolding restores the original exactly.
467        assert_eq!(folded.replace("\r\n ", ""), line);
468    }
469
470    #[test]
471    fn rotation_falls_back_to_the_creation_date() {
472        // A 90-day key that has never been rotated is due 90 days after it was
473        // issued. Reporting "never" for it is how a cadence silently does
474        // nothing.
475        let e =
476            json!({ "provider": "X", "created_at": "2026-01-01T00:00:00Z", "rotation_days": 90 });
477        assert_eq!(
478            to_ics_date(rotation_due(&e).as_deref()),
479            Some("20260401".to_string())
480        );
481    }
482
483    #[test]
484    fn a_licence_and_an_identity_document_put_their_own_dates_on_the_calendar() {
485        let lic = json!({ "id": "l1", "provider": "Editor", "secretType": "license_key",
486            "extra_vars": [{ "key": "maintenance_until", "value": "2027-03-01" }] });
487        let doc = json!({ "id": "d1", "provider": "Passport", "secretType": "identity_document",
488            "account_name": "Holder Name", "api_key": "X1234567",
489            "extra_vars": [{ "key": "expires", "value": "2031-05-09" }] });
490        let ics = build_ics(&[lic, doc], &IcsOptions::default());
491        assert!(ics.contains("SUMMARY:Maintenance ends: Editor"), "{ics}");
492        assert!(ics.contains("DTSTART;VALUE=DATE:20270301"));
493        assert!(ics.contains("SUMMARY:Expires: Passport"));
494        assert!(ics.contains("20310509"));
495        // PII stays out of the feed: not the number, not the holder.
496        assert!(
497            !ics.contains("X1234567") && !ics.contains("Holder Name"),
498            "{ics}"
499        );
500    }
501
502    #[test]
503    fn an_entry_with_no_anchor_gets_no_rotation_event() {
504        let e = json!({ "provider": "X", "rotation_days": 90 });
505        assert!(rotation_due(&e).is_none());
506    }
507
508    #[test]
509    fn no_secret_value_reaches_the_calendar() {
510        // The guarantee the whole module exists to keep. An .ics is handed to a
511        // third-party calendar; a value in it is disclosed to that service.
512        let e = json!({
513            "id": "e1",
514            "provider": "GitHub",
515            "api_key": "ghp_SUPERSECRETVALUE",
516            "api_secret": "second-secret",
517            "created_at": "2026-01-01T00:00:00Z",
518            "expires_at": "2026-06-01T00:00:00Z"
519        });
520        let ics = build_ics(&[e], &IcsOptions::default());
521        assert!(!ics.contains("ghp_SUPERSECRETVALUE"));
522        assert!(!ics.contains("second-secret"));
523        assert!(ics.contains("SUMMARY:Created: GitHub"));
524    }
525
526    #[test]
527    fn uids_are_stable_so_a_re_import_updates_rather_than_duplicates() {
528        let e = json!({ "id": "abc", "provider": "X", "expires_at": "2026-06-01T00:00:00Z" });
529        let a = build_ics(std::slice::from_ref(&e), &IcsOptions::default());
530        let b = build_ics(&[e], &IcsOptions::default());
531        assert!(a.contains("UID:abc-expires@unenverse"));
532        assert_eq!(event_count(&a), 1);
533        assert_eq!(event_count(&b), 1);
534    }
535
536    #[test]
537    fn all_day_events_end_on_the_following_day() {
538        // An all-day DTEND is exclusive. Ending on the same day makes the event
539        // zero-length, and several clients then drop it entirely.
540        assert_eq!(next_day("20261231"), "20270101");
541        assert_eq!(next_day("20260228"), "20260301");
542    }
543}