1use serde_json::Value;
21use time::OffsetDateTime;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum EventKind {
26 Created,
27 Expires,
28 Rotation,
29}
30
31impl EventKind {
32 fn category(self) -> &'static str {
35 match self {
36 EventKind::Created => "CREATED",
37 EventKind::Expires => "EXPIRES",
38 EventKind::Rotation => "ROTATION",
39 }
40 }
41
42 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
53pub struct IcsOptions {
55 pub kinds: Vec<EventKind>,
56 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
84pub 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 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
108pub 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 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
139fn 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
158pub 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
169pub 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
184pub 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
207pub 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
221pub 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 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 alarm_days_before: 7,
316 });
317 }
318 }
319
320 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 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
383pub 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 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 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 format!("{}\r\n", folded.join("\r\n"))
434}
435
436pub 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 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 assert_eq!(folded.replace("\r\n ", ""), line);
468 }
469
470 #[test]
471 fn rotation_falls_back_to_the_creation_date() {
472 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 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 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 assert_eq!(next_day("20261231"), "20270101");
541 assert_eq!(next_day("20260228"), "20260301");
542 }
543}