Skip to main content

silicon_browser_shared/
filter.rs

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/// Case-insensitive CLI text pattern. `^text` anchors a prefix and `*` is a
27/// zero-or-more wildcard; an unadorned pattern is an exact match.
28#[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    /// Every pipeline stage narrows the preceding result, so predicates are ANDed.
120    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    /// The caller must establish visibility before applying this filter.
192    /// `is:shared` consequently means visible to `viewer`, but not owned by it,
193    /// regardless of whether visibility came from participation or profile ACLs.
194    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 group: session pipeline stages are service-specific and ANDed.
445    #[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 group: incognito is a mode facet, independent of terminal status.
458    #[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 group: recording contains searches both discoverability fields.
470    #[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 group: recording discovery composes profile, actor, and source-session metadata.
479    #[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 group: shared means an already-visible recording not owned by the
500    /// viewer, including profile-ACL visibility without session participation.
501    #[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 group: usage date windows are inclusive and compose with actor selection.
516    #[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 group: parsers reject cross-service predicates and invalid ranges.
524    #[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 group: an omitted filter is represented by an empty, match-all pipeline.
534    #[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 group: wildcard and caret examples in UNDERSTANDING.md have prefix semantics.
542    #[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 group: filter pipelines, patterns, and principals have finite parsing work.
550    #[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}