1use std::str::FromStr;
2
3use chrono::NaiveDate;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{Identity, Recording, Session, SessionStatus, Usage};
8
9const MAX_FILTER_CHARS: usize = 16_384;
10const MAX_FILTER_STAGES: usize = 64;
11const MAX_FILTER_VALUE_CHARS: usize = 4_096;
12const MAX_FILTER_PRINCIPAL_CHARS: usize = 512;
13
14#[derive(Clone, Debug, PartialEq, Eq, Error)]
15pub enum FilterError {
16 #[error("filter stage {stage} is empty")]
17 EmptyStage { stage: usize },
18 #[error("filter stage {stage} must use key:value")]
19 MissingColon { stage: usize },
20 #[error("{service} filters do not support {key}")]
21 Unsupported { service: &'static str, key: String },
22 #[error("invalid {key} filter: {reason}")]
23 Invalid { key: String, reason: String },
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(transparent)]
30pub struct TextPattern(String);
31
32impl TextPattern {
33 pub fn new(value: impl Into<String>) -> Result<Self, FilterError> {
34 let value = value.into();
35 if value.trim().is_empty() {
36 return Err(FilterError::Invalid { key: "pattern".into(), reason: "value is required".into() });
37 }
38 if value.chars().count() > MAX_FILTER_VALUE_CHARS {
39 return Err(FilterError::Invalid {
40 key: "pattern".into(),
41 reason: format!("must be at most {MAX_FILTER_VALUE_CHARS} characters"),
42 });
43 }
44 Ok(Self(value.trim().to_owned()))
45 }
46
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50
51 pub fn matches(&self, candidate: &str) -> bool {
52 let pattern = self.0.to_lowercase();
53 let candidate = candidate.to_lowercase();
54 if let Some(prefix) = pattern.strip_prefix('^') {
55 if prefix.contains('*') {
56 return glob_matches(prefix, &candidate, true);
57 }
58 return candidate.starts_with(prefix);
59 }
60 glob_matches(&pattern, &candidate, false)
61 }
62}
63
64fn glob_matches(pattern: &str, candidate: &str, prefix_only: bool) -> bool {
65 if !pattern.contains('*') {
66 return pattern == candidate;
67 }
68
69 let starts_with_wildcard = pattern.starts_with('*');
70 let ends_with_wildcard = pattern.ends_with('*');
71 let parts: Vec<_> = pattern.split('*').filter(|part| !part.is_empty()).collect();
72 if parts.is_empty() {
73 return true;
74 }
75
76 let mut offset = 0;
77 for (index, part) in parts.iter().enumerate() {
78 let Some(found) = candidate[offset..].find(part) else {
79 return false;
80 };
81 if index == 0 && !starts_with_wildcard && found != 0 {
82 return false;
83 }
84 offset += found + part.len();
85 }
86
87 prefix_only || ends_with_wildcard || offset == candidate.len()
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum SessionFacet {
93 Active,
94 Ended,
95 Expired,
96 Incognito,
97 Mine,
98}
99
100#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "field", content = "value", rename_all = "snake_case")]
102pub enum SessionPredicate {
103 Is(SessionFacet),
104 For(String),
105 Name(TextPattern),
106 Description(TextPattern),
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
110pub struct SessionFilter {
111 pub predicates: Vec<SessionPredicate>,
112}
113
114impl SessionFilter {
115 pub fn parse(value: &str) -> Result<Self, FilterError> {
116 value.parse()
117 }
118
119 pub fn matches(&self, session: &Session, viewer_id: &str) -> bool {
121 self.predicates.iter().all(|predicate| match predicate {
122 SessionPredicate::Is(facet) => match facet {
123 SessionFacet::Active => session.status == SessionStatus::Active,
124 SessionFacet::Ended => session.status == SessionStatus::Ended,
125 SessionFacet::Expired => session.status == SessionStatus::Expired,
126 SessionFacet::Incognito => session.incognito,
127 SessionFacet::Mine => session.is_participant(viewer_id),
128 },
129 SessionPredicate::For(identity_id) => session.is_participant(identity_id),
130 SessionPredicate::Name(pattern) => pattern.matches(&session.name),
131 SessionPredicate::Description(pattern) => pattern.matches(&session.description),
132 })
133 }
134}
135
136impl FromStr for SessionFilter {
137 type Err = FilterError;
138
139 fn from_str(value: &str) -> Result<Self, Self::Err> {
140 let mut predicates = Vec::new();
141 for (stage, key, value) in stages(value)? {
142 let predicate = match key.as_str() {
143 "is" => SessionPredicate::Is(match value.to_ascii_lowercase().as_str() {
144 "active" => SessionFacet::Active,
145 "ended" => SessionFacet::Ended,
146 "expired" => SessionFacet::Expired,
147 "incognito" => SessionFacet::Incognito,
148 "mine" => SessionFacet::Mine,
149 _ => return Err(invalid(&key, "expected active, ended, expired, incognito, or mine")),
150 }),
151 "for" => SessionPredicate::For(principal(&key, &value)?),
152 "name" => SessionPredicate::Name(pattern(&key, &value)?),
153 "description" => SessionPredicate::Description(pattern(&key, &value)?),
154 _ => return Err(FilterError::Unsupported { service: "session", key: stage_key(stage, key) }),
155 };
156 predicates.push(predicate);
157 }
158 Ok(Self { predicates })
159 }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum RecordingFacet {
165 Incognito,
166 Mine,
167 Shared,
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "field", content = "value", rename_all = "snake_case")]
172pub enum RecordingPredicate {
173 Contains(String),
174 Profile(String),
175 For(String),
176 Name(TextPattern),
177 Description(TextPattern),
178 Is(RecordingFacet),
179}
180
181#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
182pub struct RecordingFilter {
183 pub predicates: Vec<RecordingPredicate>,
184}
185
186impl RecordingFilter {
187 pub fn parse(value: &str) -> Result<Self, FilterError> {
188 value.parse()
189 }
190
191 pub fn matches(&self, recording: &Recording, viewer: &Identity) -> bool {
195 self.predicates.iter().all(|predicate| match predicate {
196 RecordingPredicate::Contains(needle) => {
197 let needle = needle.to_lowercase();
198 recording.session_name.to_lowercase().contains(&needle)
199 || recording.session_description.to_lowercase().contains(&needle)
200 }
201 RecordingPredicate::Profile(profile_id) => recording.profile_id.as_deref() == Some(profile_id),
202 RecordingPredicate::For(identity_id) => recording.is_participant(identity_id),
203 RecordingPredicate::Name(pattern) => pattern.matches(&recording.session_name),
204 RecordingPredicate::Description(pattern) => pattern.matches(&recording.session_description),
205 RecordingPredicate::Is(RecordingFacet::Incognito) => recording.incognito,
206 RecordingPredicate::Is(RecordingFacet::Mine) => viewer.matches_principal(&recording.owner_id),
207 RecordingPredicate::Is(RecordingFacet::Shared) => !viewer.matches_principal(&recording.owner_id),
208 })
209 }
210}
211
212impl FromStr for RecordingFilter {
213 type Err = FilterError;
214
215 fn from_str(value: &str) -> Result<Self, Self::Err> {
216 let mut predicates = Vec::new();
217 for (stage, key, value) in stages(value)? {
218 let predicate = match key.as_str() {
219 "contains" if value.chars().count() > MAX_FILTER_VALUE_CHARS => {
220 return Err(invalid(&key, &format!("must be at most {MAX_FILTER_VALUE_CHARS} characters")));
221 }
222 "contains" if !value.is_empty() => RecordingPredicate::Contains(value),
223 "contains" => return Err(invalid(&key, "value is required")),
224 "profile" => RecordingPredicate::Profile(resource_id(&key, &value, "profile id")?),
225 "for" => RecordingPredicate::For(principal(&key, &value)?),
226 "name" => RecordingPredicate::Name(pattern(&key, &value)?),
227 "description" => RecordingPredicate::Description(pattern(&key, &value)?),
228 "is" => RecordingPredicate::Is(match value.to_ascii_lowercase().as_str() {
229 "incognito" => RecordingFacet::Incognito,
230 "mine" => RecordingFacet::Mine,
231 "shared" => RecordingFacet::Shared,
232 _ => return Err(invalid(&key, "expected incognito, mine, or shared")),
233 }),
234 _ => return Err(FilterError::Unsupported { service: "recording", key: stage_key(stage, key) }),
235 };
236 predicates.push(predicate);
237 }
238 Ok(Self { predicates })
239 }
240}
241
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(tag = "field", content = "value", rename_all = "snake_case")]
244pub enum UsagePredicate {
245 Between { start: NaiveDate, end: NaiveDate },
246 For(String),
247}
248
249#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
250pub struct UsageFilter {
251 pub predicates: Vec<UsagePredicate>,
252}
253
254impl UsageFilter {
255 pub fn parse(value: &str) -> Result<Self, FilterError> {
256 value.parse()
257 }
258
259 pub fn matches(&self, usage: &Usage) -> bool {
260 self.predicates.iter().all(|predicate| match predicate {
261 UsagePredicate::Between { start, end } => {
262 let date = usage.started_at.date_naive();
263 (*start..=*end).contains(&date)
264 }
265 UsagePredicate::For(identity_id) => usage.is_for(identity_id),
266 })
267 }
268}
269
270impl FromStr for UsageFilter {
271 type Err = FilterError;
272
273 fn from_str(value: &str) -> Result<Self, Self::Err> {
274 let mut predicates = Vec::new();
275 for (stage, key, value) in stages(value)? {
276 let predicate = match key.as_str() {
277 "between" => {
278 let (start, end) =
279 value.split_once('=').ok_or_else(|| invalid(&key, "expected DD-MM-YYYY=DD-MM-YYYY"))?;
280 let start = parse_date(&key, start)?;
281 let end = parse_date(&key, end)?;
282 if start > end {
283 return Err(invalid(&key, "start must not be later than end"));
284 }
285 UsagePredicate::Between { start, end }
286 }
287 "for" => UsagePredicate::For(principal(&key, &value)?),
288 _ => return Err(FilterError::Unsupported { service: "usage", key: stage_key(stage, key) }),
289 };
290 predicates.push(predicate);
291 }
292 Ok(Self { predicates })
293 }
294}
295
296fn stages(value: &str) -> Result<Vec<(usize, String, String)>, FilterError> {
297 if value.trim().is_empty() {
298 return Ok(Vec::new());
299 }
300 if value.chars().count() > MAX_FILTER_CHARS {
301 return Err(invalid("filter", &format!("must be at most {MAX_FILTER_CHARS} characters")));
302 }
303 value
304 .split("->")
305 .enumerate()
306 .map(|(index, raw)| {
307 let stage = index + 1;
308 if stage > MAX_FILTER_STAGES {
309 return Err(invalid("filter", &format!("must contain at most {MAX_FILTER_STAGES} stages")));
310 }
311 let raw = raw.trim();
312 if raw.is_empty() {
313 return Err(FilterError::EmptyStage { stage });
314 }
315 let (key, value) = raw.split_once(':').ok_or(FilterError::MissingColon { stage })?;
316 let key = key.trim().to_ascii_lowercase();
317 let value = value.trim().to_owned();
318 if key.is_empty() {
319 return Err(FilterError::MissingColon { stage });
320 }
321 Ok((stage, key, value))
322 })
323 .collect()
324}
325
326fn principal(key: &str, value: &str) -> Result<String, FilterError> {
327 let value = value.trim().trim_start_matches('@');
328 if value.is_empty()
329 || value.chars().count() > MAX_FILTER_PRINCIPAL_CHARS
330 || value.chars().any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '/' | '\\'))
331 {
332 return Err(invalid(key, "expected @identity"));
333 }
334 Ok(value.to_owned())
335}
336
337fn resource_id(key: &str, value: &str, expected: &str) -> Result<String, FilterError> {
338 let value = value.trim();
339 if value.is_empty()
340 || value.chars().count() > MAX_FILTER_PRINCIPAL_CHARS
341 || value.chars().any(|ch| ch.is_control() || ch.is_whitespace() || matches!(ch, '/' | '\\'))
342 {
343 return Err(invalid(key, &format!("expected {expected}")));
344 }
345 Ok(value.to_owned())
346}
347
348fn pattern(key: &str, value: &str) -> Result<TextPattern, FilterError> {
349 TextPattern::new(value).map_err(|_| invalid(key, "value is required"))
350}
351
352fn parse_date(key: &str, value: &str) -> Result<NaiveDate, FilterError> {
353 NaiveDate::parse_from_str(value.trim(), "%d-%m-%Y").map_err(|_| invalid(key, "expected a valid DD-MM-YYYY date"))
354}
355
356fn invalid(key: &str, reason: &str) -> FilterError {
357 FilterError::Invalid { key: key.to_owned(), reason: reason.to_owned() }
358}
359
360fn stage_key(stage: usize, key: String) -> String {
361 format!("{key} (stage {stage})")
362}
363
364#[cfg(test)]
365mod filter_tests {
366 use chrono::{TimeZone, Utc};
367
368 use super::*;
369 use crate::{IdentityKind, Money, ProxyLocation, RecordingStatus, SessionTtl, UsageCost, UsageTotal};
370
371 fn identity(id: &str) -> Identity {
372 Identity {
373 id: id.into(),
374 name: id.into(),
375 kind: IdentityKind::Silicon,
376 tags: Vec::new(),
377 verified_aliases: Vec::new(),
378 }
379 }
380
381 fn session() -> Session {
382 let started_at = Utc.with_ymd_and_hms(2026, 8, 10, 12, 0, 0).unwrap();
383 Session {
384 id: "session-1".into(),
385 profile_id: Some("profile-1".into()),
386 incognito: false,
387 location: Some(ProxyLocation { code: "in".into(), name: "India".into(), country: Some("IN".into()) }),
388 name: "Market scan".into(),
389 description: "Research browser vendors".into(),
390 status: SessionStatus::Active,
391 initiator_id: "silicon-1".into(),
392 participant_ids: vec!["carbon-1".into()],
393 ttl: SessionTtl::Minutes30,
394 started_at,
395 expires_at: started_at + chrono::Duration::minutes(30),
396 ended_at: None,
397 end_note: None,
398 usage: UsageTotal::default(),
399 }
400 }
401
402 fn recording() -> Recording {
403 Recording {
404 session_id: "session-1".into(),
405 profile_id: Some("profile-1".into()),
406 incognito: false,
407 session_name: "Market scan".into(),
408 session_description: "Research browser vendors".into(),
409 owner_id: "silicon-1".into(),
410 participant_ids: vec!["silicon-1".into(), "carbon-1".into()],
411 briefcase_path: "private/silicon-1/sb/session-1".into(),
412 briefcase_link: None,
413 command_log_link: None,
414 command_log_path: None,
415 delivery_error: None,
416 duration_seconds: 60,
417 size_bytes: 100,
418 status: RecordingStatus::Pending,
419 created_at: Utc.with_ymd_and_hms(2026, 8, 10, 12, 0, 0).unwrap(),
420 trashed_at: None,
421 purge_at: None,
422 }
423 }
424
425 fn usage() -> Usage {
426 Usage {
427 session_id: "session-1".into(),
428 started_at: Utc.with_ymd_and_hms(2026, 8, 10, 12, 0, 0).unwrap(),
429 principal_ids: vec!["silicon-1".into(), "carbon-1".into()],
430 browser_seconds: 60,
431 proxy_bytes_in: 0,
432 proxy_bytes_out: 0,
433 proxy_bytes_unclassified: 0,
434 cost: UsageCost {
435 browser: Money::default(),
436 proxy_in: Money::default(),
437 proxy_out: Money::default(),
438 proxy_unclassified: Money::default(),
439 total: Money::default(),
440 },
441 }
442 }
443
444 #[test]
446 fn session_filters_status_people_and_text() {
447 let filter =
448 SessionFilter::parse("is: active -> for: @carbon-1 -> name:market* -> description:^research").unwrap();
449 assert!(filter.matches(&session(), "someone-else"));
450
451 let ended = SessionFilter::parse("is:ended").unwrap();
452 assert!(!ended.matches(&session(), "silicon-1"));
453 let mine = SessionFilter::parse("is:mine").unwrap();
454 assert!(mine.matches(&session(), "carbon-1"));
455 }
456
457 #[test]
459 fn session_incognito_filter_uses_mode() {
460 let mut value = session();
461 value.profile_id = None;
462 value.location = None;
463 value.incognito = true;
464 value.status = SessionStatus::Expired;
465 assert!(SessionFilter::parse("is:incognito").unwrap().matches(&value, "silicon-1"));
466 assert!(SessionFilter::parse("is:expired").unwrap().matches(&value, "silicon-1"));
467 }
468
469 #[test]
471 fn recording_contains_name_or_description() {
472 let viewer = identity("silicon-1");
473 assert!(RecordingFilter::parse("contains:market").unwrap().matches(&recording(), &viewer));
474 assert!(RecordingFilter::parse("contains:VENDOR").unwrap().matches(&recording(), &viewer));
475 assert!(!RecordingFilter::parse("contains:checkout").unwrap().matches(&recording(), &viewer));
476 }
477
478 #[test]
480 fn recording_filters_profile_actor_text_and_incognito() {
481 let viewer = identity("silicon-1");
482 let filter =
483 RecordingFilter::parse("profile:profile-1 -> for:@carbon-1 -> name:market* -> description:^research")
484 .unwrap();
485 assert!(filter.matches(&recording(), &viewer));
486 assert!(!RecordingFilter::parse("profile:profile-2").unwrap().matches(&recording(), &viewer));
487
488 let mut owner_is_implicit_actor = recording();
489 owner_is_implicit_actor.participant_ids.retain(|participant| participant != "silicon-1");
490 assert!(RecordingFilter::parse("for:@silicon-1").unwrap().matches(&owner_is_implicit_actor, &viewer));
491
492 let mut incognito = recording();
493 incognito.profile_id = None;
494 incognito.incognito = true;
495 assert!(RecordingFilter::parse("is:incognito").unwrap().matches(&incognito, &viewer));
496 assert!(!RecordingFilter::parse("profile:profile-1").unwrap().matches(&incognito, &viewer));
497 }
498
499 #[test]
502 fn recording_mine_and_shared_are_viewer_relative() {
503 let owner = identity("silicon-1");
504 let acl_viewer = identity("acl-viewer");
505 assert!(RecordingFilter::parse("is:mine").unwrap().matches(&recording(), &owner));
506 assert!(RecordingFilter::parse("is:shared").unwrap().matches(&recording(), &acl_viewer));
507 assert!(!RecordingFilter::parse("is:shared").unwrap().matches(&recording(), &owner));
508
509 let mut aliased_owner = identity("public-silicon");
510 aliased_owner.verified_aliases.push("silicon-1".into());
511 assert!(RecordingFilter::parse("is:mine").unwrap().matches(&recording(), &aliased_owner));
512 assert!(!RecordingFilter::parse("is:shared").unwrap().matches(&recording(), &aliased_owner));
513 }
514
515 #[test]
517 fn usage_between_and_for_are_inclusive() {
518 let filter = UsageFilter::parse("between:10-08-2026=10-08-2026 -> for:@carbon-1").unwrap();
519 assert!(filter.matches(&usage()));
520 assert!(!UsageFilter::parse("between:11-08-2026=12-08-2026").unwrap().matches(&usage()));
521 }
522
523 #[test]
525 fn invalid_service_predicates_do_not_leak_between_services() {
526 assert!(SessionFilter::parse("contains:research").is_err());
527 assert!(RecordingFilter::parse("between:01-08-2026=30-08-2026").is_err());
528 assert!(RecordingFilter::parse("profile:profile/escape").is_err());
529 assert!(UsageFilter::parse("between:30-08-2026=01-08-2026").is_err());
530 assert!(UsageFilter::parse("between:31-02-2026=01-03-2026").is_err());
531 }
532
533 #[test]
535 fn empty_filters_match_everything() {
536 assert!(SessionFilter::parse("").unwrap().matches(&session(), "nobody"));
537 assert!(RecordingFilter::parse(" ").unwrap().matches(&recording(), &identity("nobody")));
538 assert!(UsageFilter::default().matches(&usage()));
539 }
540
541 #[test]
543 fn documented_text_patterns_match() {
544 assert!(TextPattern::new("market*").unwrap().matches("Market scan"));
545 assert!(TextPattern::new("^research").unwrap().matches("Research browser vendors"));
546 assert!(!TextPattern::new("market*").unwrap().matches("A market scan"));
547 }
548
549 #[test]
551 fn filter_input_work_is_bounded() {
552 let too_many_stages = vec!["is:active"; MAX_FILTER_STAGES + 1].join(" -> ");
553 assert!(SessionFilter::parse(&too_many_stages).is_err());
554
555 let oversized_filter = format!("name:{}", "x".repeat(MAX_FILTER_CHARS));
556 assert!(SessionFilter::parse(&oversized_filter).is_err());
557 assert!(TextPattern::new("x".repeat(MAX_FILTER_VALUE_CHARS + 1)).is_err());
558 assert!(RecordingFilter::parse(&format!("contains:{}", "x".repeat(MAX_FILTER_VALUE_CHARS + 1))).is_err());
559 assert!(RecordingFilter::parse(&format!("profile:{}", "x".repeat(MAX_FILTER_PRINCIPAL_CHARS + 1))).is_err());
560 assert!(RecordingFilter::parse("for:@person\0escape").is_err());
561 assert!(SessionFilter::parse(&format!("for:@{}", "x".repeat(MAX_FILTER_PRINCIPAL_CHARS + 1))).is_err());
562 assert!(SessionFilter::parse("for:@person\0escape").is_err());
563 }
564}