Skip to main content

pharos_native/runtime/event_discovery/
calendar_feed.rs

1//! Bounded iCalendar intake for explicit UTC, non-recurring performance events.
2//! Unsupported calendar semantics fail visibly rather than inventing a time zone or recurrence.
3
4use super::{BTreeMap, DiscoveredEvent, Value, event_from_value, json};
5
6/// Decodes RFC 5545 TEXT escapes without evaluating markup or calendar attachments.
7fn text(value: &str) -> Result<String, &'static str> {
8    let mut result = String::new();
9    let mut chars = value.chars();
10    while let Some(ch) = chars.next() {
11        if ch == '\\' {
12            result.push(match chars.next() {
13                Some('n' | 'N') => '\n',
14                Some(ch @ ('\\' | ',' | ';')) => ch,
15                _ => return Err("Invalid calendar text escape"),
16            });
17        } else if ch.is_control() {
18            return Err("Invalid calendar text control");
19        } else {
20            result.push(ch);
21        }
22    }
23    Ok(result)
24}
25
26/// Accepts explicit UTC date-times only; local/all-day/recurring dates need separate review.
27fn utc(value: &str) -> Result<String, &'static str> {
28    let bytes = value.as_bytes();
29    if bytes.len() != 16
30        || bytes[8] != b'T'
31        || bytes[15] != b'Z'
32        || !bytes
33            .iter()
34            .enumerate()
35            .all(|(i, ch)| matches!(i, 8 | 15) || ch.is_ascii_digit())
36    {
37        return Err("Calendar requires explicit UTC date-times");
38    }
39    let date = format!(
40        "{}-{}-{}T{}:{}:{}Z",
41        &value[..4],
42        &value[4..6],
43        &value[6..8],
44        &value[9..11],
45        &value[11..13],
46        &value[13..15]
47    );
48    super::runtime_timestamp_seconds(&date).ok_or("Invalid calendar date-time")?;
49    Ok(date)
50}
51
52/// Converts a complete, explicitly attributed calendar event into the common evidence validator.
53fn event(
54    fields: &BTreeMap<String, String>,
55    artist: &str,
56    source: &str,
57    now: u64,
58) -> Result<Option<DiscoveredEvent>, &'static str> {
59    let required = |key: &str| {
60        fields
61            .get(key)
62            .filter(|v| !v.is_empty())
63            .ok_or("Calendar event is missing required evidence")
64    };
65    let uid = required("UID")?;
66    if uid.len() > 512 {
67        return Err("Calendar event identity exceeds limit");
68    }
69    let performer = required("X-PHAROS-PERFORMER")?;
70    let start = utc(required("DTSTART")?)?;
71    let end = fields.get("DTEND").map(|value| utc(value)).transpose()?;
72    if end.as_ref().is_some_and(|end| end < &start) {
73        return Err("Calendar end precedes start");
74    }
75    let status = match fields.get("STATUS").map(String::as_str) {
76        None | Some("CONFIRMED") => "https://schema.org/EventScheduled",
77        Some("CANCELLED") => "https://schema.org/EventCancelled",
78        Some("TENTATIVE") => "https://schema.org/EventPostponed",
79        _ => return Err("Unsupported calendar event status"),
80    };
81    let mut evidence = json!({"@type":"MusicEvent", "name":text(required("SUMMARY")?)?, "performer":{"name":text(performer)?}, "startDate": start, "location":{"name":text(required("LOCATION")?)?}, "eventStatus":status});
82    if let Some(end) = end {
83        evidence["endDate"] = Value::String(end);
84    }
85    if let Some(url) = fields.get("URL") {
86        evidence["url"] =
87            Value::String(super::public_link(url).ok_or("Unsafe calendar event URL")?);
88    }
89    if let Some(url) = fields.get("X-PHAROS-TICKET-URL") {
90        evidence["offers"] =
91            json!({"url":super::public_link(url).ok_or("Unsafe calendar ticket URL")?});
92    }
93    let Some(mut event) = event_from_value(&evidence, artist, source, now) else {
94        return Ok(None);
95    };
96    event.state_explicit = fields.contains_key("STATUS");
97    // UID, scoped to the configured feed, survives explicit rescheduling and URL changes.
98    event.key = pharos_runtime_database::operations::operation_fingerprint(&(source, uid))
99        .map_err(|_| "Invalid calendar identity")?;
100    event.evidence = serde_json::to_string(
101        &json!({"extraction_version":1,"calendar":fields,"normalized":evidence}),
102    )
103    .map_err(|_| "Invalid calendar evidence")?;
104    if event.evidence.len() > 8192 {
105        return Err("Calendar event evidence exceeds limit");
106    }
107    Ok(Some(event))
108}
109
110/// Parses at most 100 explicit UTC events, preserving UID identity and rejecting partial feeds.
111pub(super) fn extract(
112    body: &str,
113    artist: &str,
114    source: &str,
115    now: u64,
116) -> Result<Vec<DiscoveredEvent>, &'static str> {
117    if body.len() > 1024 * 1024 {
118        return Err("Calendar exceeds input limit");
119    }
120    let mut lines: Vec<String> = Vec::new();
121    for line in body.lines() {
122        if line.starts_with([' ', '\t']) {
123            let last = lines.last_mut().ok_or("Invalid folded calendar line")?;
124            if last.len().saturating_add(line.len()) > 8192 {
125                return Err("Calendar property exceeds limit");
126            }
127            last.push_str(&line[1..]);
128        } else {
129            if line.len() > 8192 || lines.len() >= 20000 {
130                return Err("Calendar property limit exceeded");
131            }
132            lines.push(line.to_owned());
133        }
134    }
135    let mut calendar = false;
136    let mut complete = false;
137    let mut fields: Option<BTreeMap<String, String>> = None;
138    let mut output = BTreeMap::new();
139    let mut count = 0;
140    for line in lines {
141        if line.is_empty() {
142            continue;
143        }
144        if complete {
145            return Err("Trailing calendar content");
146        }
147        let (key, value) = line.split_once(':').ok_or("Malformed calendar property")?;
148        match (key, value) {
149            ("BEGIN", "VCALENDAR") if !calendar => calendar = true,
150            ("END", "VCALENDAR") if calendar && fields.is_none() => complete = true,
151            ("BEGIN", "VEVENT") if calendar && fields.is_none() => {
152                count += 1;
153                if count > 100 {
154                    return Err("Calendar event limit exceeded");
155                }
156                fields = Some(BTreeMap::new());
157            }
158            ("END", "VEVENT") if fields.is_some() => {
159                if let Some(event) = event(&fields.take().unwrap_or_default(), artist, source, now)?
160                    && output.insert(event.key.clone(), event).is_some()
161                {
162                    return Err("Duplicate calendar UID");
163                }
164            }
165            ("BEGIN" | "END", _) => return Err("Unsupported calendar component"),
166            _ if fields.is_some() => {
167                // Avoid silently interpreting recurrence, floating time or TZID as a single UTC gig.
168                if key.contains(';')
169                    || matches!(
170                        key,
171                        "RRULE" | "RDATE" | "EXDATE" | "RECURRENCE-ID" | "DURATION"
172                    )
173                {
174                    return Err("Calendar requires explicit non-recurring UTC events");
175                }
176                if fields
177                    .as_mut()
178                    .ok_or("Invalid calendar event")?
179                    .insert(key.to_owned(), value.to_owned())
180                    .is_some()
181                {
182                    return Err("Duplicate calendar event property");
183                }
184            }
185            _ if calendar
186                && matches!(
187                    key,
188                    "VERSION" | "PRODID" | "CALSCALE" | "METHOD" | "X-WR-CALNAME"
189                ) => {}
190            _ => return Err("Unsupported calendar property"),
191        }
192    }
193    if !complete || fields.is_some() {
194        return Err("Incomplete calendar document");
195    }
196    Ok(output.into_values().collect())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    /// Verifies folded evidence, UTC normalization, stable rescheduling and explicit cancellation.
204    #[test]
205    fn utc_calendar_retains_identity_and_rejects_ambiguous_dates() {
206        let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:night-1\r\nSUMMARY:Night\\, Live\r\nDTSTART:20270101T200000Z\r\nLOCATION:Great\r\n Hall\r\nX-PHAROS-PERFORMER:Mad Maxx\r\nURL:https://events.example/night\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
207        let source = "https://artist.example/events.ics";
208        let first = extract(body, "MAD MAXX", source, 0).unwrap();
209        // Use a near-enough clock for the two-year publication horizon.
210        let now = super::super::runtime_timestamp_seconds("2026-10-21T00:00:00Z").unwrap();
211        assert!(first.is_empty());
212        let first = extract(body, "MAD MAXX", source, now).unwrap();
213        assert_eq!(first.len(), 1);
214        assert_eq!(first[0].venue, "GreatHall");
215        let changed = body
216            .replace("20270101T200000Z", "20270201T210000Z")
217            .replace("END:VEVENT", "STATUS:CANCELLED\r\nEND:VEVENT");
218        let changed = extract(&changed, "Mad Maxx", source, now).unwrap();
219        assert_eq!(first[0].key, changed[0].key);
220        assert_eq!(changed[0].status, "cancelled");
221        assert!(
222            extract(
223                &body.replace("DTSTART:", "DTSTART;TZID=Europe/Paris:"),
224                "Mad Maxx",
225                source,
226                now
227            )
228            .is_err()
229        );
230        assert!(
231            extract(
232                &body.replace("END:VEVENT", "RRULE:FREQ=DAILY\r\nEND:VEVENT"),
233                "Mad Maxx",
234                source,
235                now
236            )
237            .is_err()
238        );
239        assert!(
240            extract(
241                &body.replace("UID:night-1", "UID:night-1\r\nUID:other"),
242                "Mad Maxx",
243                source,
244                now
245            )
246            .is_err()
247        );
248        assert!(extract(&body.replace("END:VCALENDAR", ""), "Mad Maxx", source, now).is_err());
249        assert!(
250            extract(body, "Unrelated Artist", source, now)
251                .unwrap()
252                .is_empty()
253        );
254    }
255}