pharos_native/runtime/event_discovery/
calendar_feed.rs1use super::{BTreeMap, DiscoveredEvent, Value, event_from_value, json};
5
6fn 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
26fn 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
52fn 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 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
110pub(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 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 #[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 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}