Skip to main content

fakecloud_logs/service/
mod.rs

1use async_trait::async_trait;
2use http::StatusCode;
3use serde_json::{json, Value};
4
5use std::sync::Arc;
6
7use tokio::sync::Mutex as AsyncMutex;
8
9use fakecloud_core::delivery::DeliveryBus;
10use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
11use fakecloud_persistence::SnapshotStore;
12
13use crate::state::{LogsSnapshot, SharedLogsState, LOGS_SNAPSHOT_SCHEMA_VERSION};
14
15mod anomaly;
16mod deliveries;
17mod destinations;
18mod exports;
19mod filters;
20mod groups;
21mod misc;
22mod policies;
23mod queries;
24mod streams;
25mod syslog;
26mod tags;
27
28/// CloudWatch Logs actions that do NOT mutate state. Everything else
29/// triggers a snapshot save on HTTP 2xx.
30fn is_read_only_action(action: &str) -> bool {
31    matches!(
32        action,
33        "DescribeLogGroups"
34            | "DescribeLogStreams"
35            | "GetLogEvents"
36            | "FilterLogEvents"
37            | "ListTagsLogGroup"
38            | "ListTagsForResource"
39            | "DescribeSubscriptionFilters"
40            | "DescribeMetricFilters"
41            | "DescribeResourcePolicies"
42            | "DescribeDestinations"
43            | "GetQueryResults"
44            | "DescribeQueries"
45            | "DescribeExportTasks"
46            | "GetDeliveryDestination"
47            | "DescribeDeliveryDestinations"
48            | "GetDeliveryDestinationPolicy"
49            | "GetDeliverySource"
50            | "DescribeDeliverySources"
51            | "GetDelivery"
52            | "DescribeDeliveries"
53            | "DescribeQueryDefinitions"
54            | "DescribeAccountPolicies"
55            | "GetDataProtectionPolicy"
56            | "DescribeIndexPolicies"
57            | "DescribeFieldIndexes"
58            | "GetTransformer"
59            | "TestTransformer"
60            | "GetLogAnomalyDetector"
61            | "ListLogAnomalyDetectors"
62            | "GetLogGroupFields"
63            | "TestMetricFilter"
64            | "GetLogRecord"
65            | "ListAnomalies"
66            | "DescribeImportTasks"
67            | "DescribeImportTaskBatches"
68            | "GetIntegration"
69            | "ListIntegrations"
70            | "GetLookupTable"
71            | "DescribeLookupTables"
72            | "GetScheduledQuery"
73            | "GetScheduledQueryHistory"
74            | "ListScheduledQueries"
75            | "StartLiveTail"
76            | "ListLogGroups"
77            | "ListLogGroupsForQuery"
78            | "ListAggregateLogGroupSummaries"
79            | "GetLogObject"
80            | "GetLogFields"
81            | "ListSourcesForS3TableIntegration"
82            | "DescribeConfigurationTemplates"
83            | "ListSyslogConfigurations"
84            | "GetExportedData"
85    )
86}
87
88pub struct LogsService {
89    state: SharedLogsState,
90    delivery_bus: Arc<DeliveryBus>,
91    snapshot_store: Option<Arc<dyn SnapshotStore>>,
92    snapshot_lock: Arc<AsyncMutex<()>>,
93}
94
95impl LogsService {
96    pub fn new(state: SharedLogsState, delivery_bus: Arc<DeliveryBus>) -> Self {
97        Self {
98            state,
99            delivery_bus,
100            snapshot_store: None,
101            snapshot_lock: Arc::new(AsyncMutex::new(())),
102        }
103    }
104
105    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
106        self.snapshot_store = Some(store);
107        self
108    }
109
110    /// Persist current state as a snapshot. Held across the
111    /// clone-serialize-write sequence to prevent stale-last writes,
112    /// with serde + file I/O offloaded to the blocking pool.
113    async fn save_snapshot(&self) {
114        save_logs_snapshot(
115            &self.state,
116            self.snapshot_store.clone(),
117            &self.snapshot_lock,
118        )
119        .await;
120    }
121
122    /// Build a hook that persists the current Logs state when invoked, or `None`
123    /// in memory mode (no snapshot store). The CloudFormation provisioner
124    /// mutates `state` directly and uses this to write a CFN-provisioned
125    /// resource through to disk, the same way a direct mutating API call would.
126    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
127        let store = self.snapshot_store.clone()?;
128        let state = self.state.clone();
129        let lock = self.snapshot_lock.clone();
130        Some(Arc::new(move || {
131            let state = state.clone();
132            let store = store.clone();
133            let lock = lock.clone();
134            Box::pin(async move {
135                save_logs_snapshot(&state, Some(store), &lock).await;
136            })
137        }))
138    }
139}
140
141/// Persist the current Logs state as a snapshot. Offloads the serde + blocking
142/// file write to the Tokio blocking pool. Noop when `store` is `None` (memory
143/// mode). Shared by `LogsService::save_snapshot` and the CloudFormation
144/// provisioner's post-provision persist hook so both route through the same
145/// serialize-and-write path.
146pub async fn save_logs_snapshot(
147    state: &SharedLogsState,
148    store: Option<Arc<dyn SnapshotStore>>,
149    lock: &AsyncMutex<()>,
150) {
151    let Some(store) = store else {
152        return;
153    };
154    let _guard = lock.lock().await;
155    let snapshot = LogsSnapshot {
156        schema_version: LOGS_SNAPSHOT_SCHEMA_VERSION,
157        accounts: Some(state.read().clone()),
158        state: None,
159    };
160    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
161        let bytes = serde_json::to_vec(&snapshot)
162            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
163        store.save(&bytes)
164    })
165    .await;
166    match join {
167        Ok(Ok(())) => {}
168        Ok(Err(err)) => tracing::error!(%err, "failed to write logs snapshot"),
169        Err(err) => tracing::error!(%err, "logs snapshot task panicked"),
170    }
171}
172
173#[async_trait]
174impl AwsService for LogsService {
175    fn service_name(&self) -> &str {
176        "logs"
177    }
178
179    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
180        let mutates = !is_read_only_action(req.action.as_str());
181        let result = match req.action.as_str() {
182            "CreateLogGroup" => self.create_log_group(&req),
183            "DeleteLogGroup" => self.delete_log_group(&req),
184            "DescribeLogGroups" => self.describe_log_groups(&req),
185            "CreateLogStream" => self.create_log_stream(&req),
186            "DeleteLogStream" => self.delete_log_stream(&req),
187            "DescribeLogStreams" => self.describe_log_streams(&req),
188            "PutLogEvents" => self.put_log_events(&req),
189            "GetLogEvents" => self.get_log_events(&req),
190            "FilterLogEvents" => self.filter_log_events(&req),
191            "TagLogGroup" => self.tag_log_group(&req),
192            "UntagLogGroup" => self.untag_log_group(&req),
193            "ListTagsLogGroup" => self.list_tags_log_group(&req),
194            "TagResource" => self.tag_resource(&req),
195            "UntagResource" => self.untag_resource(&req),
196            "ListTagsForResource" => self.list_tags_for_resource(&req),
197            "PutRetentionPolicy" => self.put_retention_policy(&req),
198            "DeleteRetentionPolicy" => self.delete_retention_policy(&req),
199            "PutSubscriptionFilter" => self.put_subscription_filter(&req),
200            "DescribeSubscriptionFilters" => self.describe_subscription_filters(&req),
201            "DeleteSubscriptionFilter" => self.delete_subscription_filter(&req),
202            "PutMetricFilter" => self.put_metric_filter(&req),
203            "DescribeMetricFilters" => self.describe_metric_filters(&req),
204            "DeleteMetricFilter" => self.delete_metric_filter(&req),
205            "PutResourcePolicy" => self.put_resource_policy(&req),
206            "DescribeResourcePolicies" => self.describe_resource_policies(&req),
207            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
208            "PutDestination" => self.put_destination(&req),
209            "DescribeDestinations" => self.describe_destinations(&req),
210            "DeleteDestination" => self.delete_destination(&req),
211            "PutDestinationPolicy" => self.put_destination_policy(&req),
212            "StartQuery" => self.start_query(&req),
213            "GetQueryResults" => self.get_query_results(&req),
214            "DescribeQueries" => self.describe_queries(&req),
215            "CreateExportTask" => self.create_export_task(&req),
216            "DescribeExportTasks" => self.describe_export_tasks(&req),
217            "CancelExportTask" => self.cancel_export_task(&req),
218            "PutDeliveryDestination" => self.put_delivery_destination(&req),
219            "GetDeliveryDestination" => self.get_delivery_destination(&req),
220            "DescribeDeliveryDestinations" => self.describe_delivery_destinations(&req),
221            "DeleteDeliveryDestination" => self.delete_delivery_destination(&req),
222            "PutDeliveryDestinationPolicy" => self.put_delivery_destination_policy(&req),
223            "GetDeliveryDestinationPolicy" => self.get_delivery_destination_policy(&req),
224            "DeleteDeliveryDestinationPolicy" => self.delete_delivery_destination_policy(&req),
225            "PutDeliverySource" => self.put_delivery_source(&req),
226            "GetDeliverySource" => self.get_delivery_source(&req),
227            "DescribeDeliverySources" => self.describe_delivery_sources(&req),
228            "DeleteDeliverySource" => self.delete_delivery_source(&req),
229            "CreateDelivery" => self.create_delivery(&req),
230            "GetDelivery" => self.get_delivery(&req),
231            "DescribeDeliveries" => self.describe_deliveries(&req),
232            "DeleteDelivery" => self.delete_delivery(&req),
233            "AssociateKmsKey" => self.associate_kms_key(&req),
234            "DisassociateKmsKey" => self.disassociate_kms_key(&req),
235            "PutQueryDefinition" => self.put_query_definition(&req),
236            "DescribeQueryDefinitions" => self.describe_query_definitions(&req),
237            "DeleteQueryDefinition" => self.delete_query_definition(&req),
238            "PutAccountPolicy" => self.put_account_policy(&req),
239            "DescribeAccountPolicies" => self.describe_account_policies(&req),
240            "DeleteAccountPolicy" => self.delete_account_policy(&req),
241            "PutDataProtectionPolicy" => self.put_data_protection_policy(&req),
242            "GetDataProtectionPolicy" => self.get_data_protection_policy(&req),
243            "DeleteDataProtectionPolicy" => self.delete_data_protection_policy(&req),
244            "PutIndexPolicy" => self.put_index_policy(&req),
245            "DescribeIndexPolicies" => self.describe_index_policies(&req),
246            "DeleteIndexPolicy" => self.delete_index_policy(&req),
247            "DescribeFieldIndexes" => self.describe_field_indexes(&req),
248            "PutTransformer" => self.put_transformer(&req),
249            "GetTransformer" => self.get_transformer(&req),
250            "DeleteTransformer" => self.delete_transformer(&req),
251            "TestTransformer" => self.test_transformer(&req),
252            "CreateLogAnomalyDetector" => self.create_log_anomaly_detector(&req),
253            "GetLogAnomalyDetector" => self.get_log_anomaly_detector(&req),
254            "DeleteLogAnomalyDetector" => self.delete_log_anomaly_detector(&req),
255            "ListLogAnomalyDetectors" => self.list_log_anomaly_detectors(&req),
256            "UpdateLogAnomalyDetector" => self.update_log_anomaly_detector(&req),
257            "GetLogGroupFields" => self.get_log_group_fields(&req),
258            "TestMetricFilter" => self.test_metric_filter(&req),
259            "StopQuery" => self.stop_query(&req),
260            "PutLogGroupDeletionProtection" => self.put_log_group_deletion_protection(&req),
261            "GetLogRecord" => self.get_log_record(&req),
262            "ListAnomalies" => self.list_anomalies(&req),
263            "UpdateAnomaly" => self.update_anomaly(&req),
264            "CreateImportTask" => self.create_import_task(&req),
265            "DescribeImportTasks" => self.describe_import_tasks(&req),
266            "DescribeImportTaskBatches" => self.describe_import_task_batches(&req),
267            "CancelImportTask" => self.cancel_import_task(&req),
268            "PutIntegration" => self.put_integration(&req),
269            "GetIntegration" => self.get_integration(&req),
270            "DeleteIntegration" => self.delete_integration(&req),
271            "ListIntegrations" => self.list_integrations(&req),
272            "CreateLookupTable" => self.create_lookup_table(&req),
273            "GetLookupTable" => self.get_lookup_table(&req),
274            "DescribeLookupTables" => self.describe_lookup_tables(&req),
275            "DeleteLookupTable" => self.delete_lookup_table(&req),
276            "UpdateLookupTable" => self.update_lookup_table(&req),
277            "CreateScheduledQuery" => self.create_scheduled_query(&req),
278            "GetScheduledQuery" => self.get_scheduled_query(&req),
279            "GetScheduledQueryHistory" => self.get_scheduled_query_history(&req),
280            "ListScheduledQueries" => self.list_scheduled_queries(&req),
281            "DeleteScheduledQuery" => self.delete_scheduled_query(&req),
282            "UpdateScheduledQuery" => self.update_scheduled_query(&req),
283            "StartLiveTail" => self.start_live_tail(&req),
284            "ListLogGroups" => self.list_log_groups(&req),
285            "ListLogGroupsForQuery" => self.list_log_groups_for_query(&req),
286            "ListAggregateLogGroupSummaries" => self.list_aggregate_log_group_summaries(&req),
287            "PutBearerTokenAuthentication" => self.put_bearer_token_authentication(&req),
288            "GetLogObject" => self.get_log_object(&req),
289            "GetLogFields" => self.get_log_fields(&req),
290            "AssociateSourceToS3TableIntegration" => {
291                self.associate_source_to_s3_table_integration(&req)
292            }
293            "ListSourcesForS3TableIntegration" => self.list_sources_for_s3_table_integration(&req),
294            "DisassociateSourceFromS3TableIntegration" => {
295                self.disassociate_source_from_s3_table_integration(&req)
296            }
297            "UpdateDeliveryConfiguration" => self.update_delivery_configuration(&req),
298            "DescribeConfigurationTemplates" => self.describe_configuration_templates(&req),
299            "PutSyslogConfiguration" => self.put_syslog_configuration(&req),
300            "ListSyslogConfigurations" => self.list_syslog_configurations(&req),
301            "DeleteSyslogConfiguration" => self.delete_syslog_configuration(&req),
302            // Internal action for testing export storage
303            "GetExportedData" => self.get_exported_data(&req),
304            _ => Err(AwsServiceError::action_not_implemented("logs", &req.action)),
305        };
306        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
307            self.save_snapshot().await;
308        }
309        result
310    }
311
312    fn supported_actions(&self) -> &[&str] {
313        SUPPORTED_ACTIONS
314    }
315}
316
317const SUPPORTED_ACTIONS: &[&str] = &[
318    "CreateLogGroup",
319    "DeleteLogGroup",
320    "DescribeLogGroups",
321    "CreateLogStream",
322    "DeleteLogStream",
323    "DescribeLogStreams",
324    "PutLogEvents",
325    "GetLogEvents",
326    "FilterLogEvents",
327    "TagLogGroup",
328    "UntagLogGroup",
329    "ListTagsLogGroup",
330    "TagResource",
331    "UntagResource",
332    "ListTagsForResource",
333    "PutRetentionPolicy",
334    "DeleteRetentionPolicy",
335    "PutSubscriptionFilter",
336    "DescribeSubscriptionFilters",
337    "DeleteSubscriptionFilter",
338    "PutMetricFilter",
339    "DescribeMetricFilters",
340    "DeleteMetricFilter",
341    "PutResourcePolicy",
342    "DescribeResourcePolicies",
343    "DeleteResourcePolicy",
344    "PutDestination",
345    "DescribeDestinations",
346    "DeleteDestination",
347    "PutDestinationPolicy",
348    "StartQuery",
349    "GetQueryResults",
350    "DescribeQueries",
351    "CreateExportTask",
352    "DescribeExportTasks",
353    "CancelExportTask",
354    "PutDeliveryDestination",
355    "GetDeliveryDestination",
356    "DescribeDeliveryDestinations",
357    "DeleteDeliveryDestination",
358    "PutDeliveryDestinationPolicy",
359    "GetDeliveryDestinationPolicy",
360    "DeleteDeliveryDestinationPolicy",
361    "PutDeliverySource",
362    "GetDeliverySource",
363    "DescribeDeliverySources",
364    "DeleteDeliverySource",
365    "CreateDelivery",
366    "GetDelivery",
367    "DescribeDeliveries",
368    "DeleteDelivery",
369    "AssociateKmsKey",
370    "DisassociateKmsKey",
371    "PutQueryDefinition",
372    "DescribeQueryDefinitions",
373    "DeleteQueryDefinition",
374    "PutAccountPolicy",
375    "DescribeAccountPolicies",
376    "DeleteAccountPolicy",
377    "PutDataProtectionPolicy",
378    "GetDataProtectionPolicy",
379    "DeleteDataProtectionPolicy",
380    "PutIndexPolicy",
381    "DescribeIndexPolicies",
382    "DeleteIndexPolicy",
383    "DescribeFieldIndexes",
384    "PutTransformer",
385    "GetTransformer",
386    "DeleteTransformer",
387    "TestTransformer",
388    "CreateLogAnomalyDetector",
389    "GetLogAnomalyDetector",
390    "DeleteLogAnomalyDetector",
391    "ListLogAnomalyDetectors",
392    "UpdateLogAnomalyDetector",
393    "GetLogGroupFields",
394    "TestMetricFilter",
395    "StopQuery",
396    "PutLogGroupDeletionProtection",
397    "GetLogRecord",
398    "ListAnomalies",
399    "UpdateAnomaly",
400    "CreateImportTask",
401    "DescribeImportTasks",
402    "DescribeImportTaskBatches",
403    "CancelImportTask",
404    "PutIntegration",
405    "GetIntegration",
406    "DeleteIntegration",
407    "ListIntegrations",
408    "CreateLookupTable",
409    "GetLookupTable",
410    "DescribeLookupTables",
411    "DeleteLookupTable",
412    "UpdateLookupTable",
413    "CreateScheduledQuery",
414    "GetScheduledQuery",
415    "GetScheduledQueryHistory",
416    "ListScheduledQueries",
417    "DeleteScheduledQuery",
418    "UpdateScheduledQuery",
419    "StartLiveTail",
420    "ListLogGroups",
421    "ListLogGroupsForQuery",
422    "ListAggregateLogGroupSummaries",
423    "PutBearerTokenAuthentication",
424    "GetLogObject",
425    "GetLogFields",
426    "AssociateSourceToS3TableIntegration",
427    "ListSourcesForS3TableIntegration",
428    "DisassociateSourceFromS3TableIntegration",
429    "UpdateDeliveryConfiguration",
430    "DescribeConfigurationTemplates",
431    "PutSyslogConfiguration",
432    "ListSyslogConfigurations",
433    "DeleteSyslogConfiguration",
434];
435
436fn require_str<'a>(body: &'a Value, field: &str) -> Result<&'a str, AwsServiceError> {
437    body[field].as_str().ok_or_else(|| {
438        AwsServiceError::aws_error(
439            StatusCode::BAD_REQUEST,
440            "InvalidParameterException",
441            format!("{field} is required"),
442        )
443    })
444}
445
446/// Build a delivery destination configuration JSON object, ensuring
447/// `destinationResourceArn` is always present as a string (Smithy requirement).
448fn dd_config_json(config: &std::collections::BTreeMap<String, String>) -> Value {
449    let mut m: serde_json::Map<String, Value> =
450        config.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
451    m.entry("destinationResourceArn".to_string())
452        .or_insert_with(|| json!(""));
453    Value::Object(m)
454}
455
456/// Infer a delivery destination's type from its destination resource ARN's
457/// service. Defaults to `CWL` (the most common case) when the ARN is absent or
458/// unrecognised.
459pub fn infer_delivery_destination_type(destination_arn: Option<&String>) -> String {
460    let arn = destination_arn.map(String::as_str).unwrap_or("");
461    let service = arn.split(':').nth(2).unwrap_or("");
462    match service {
463        "s3" => "S3",
464        "firehose" => "FH",
465        "xray" => "XRAY",
466        _ => "CWL",
467    }
468    .to_string()
469}
470
471fn generate_sequence_token() -> String {
472    use std::time::{SystemTime, UNIX_EPOCH};
473    let nanos = SystemTime::now()
474        .duration_since(UNIX_EPOCH)
475        .unwrap_or_default()
476        .as_nanos();
477    // u128 max is ~3.4e38, so we limit to 38 digits to avoid overflow
478    format!("{:038}", nanos % 10u128.pow(38))
479}
480
481fn validation_error(field: &str, value: &str, constraint: &str) -> AwsServiceError {
482    AwsServiceError::aws_error(
483        StatusCode::BAD_REQUEST,
484        "InvalidParameterException",
485        format!(
486            "1 validation error detected: Value '{value}' at '{field}' failed to satisfy constraint: {constraint}"
487        ),
488    )
489}
490
491/// Resolve log group name from either logGroupName or resourceIdentifier.
492/// resourceIdentifier can be a log group name or an ARN.
493fn resolve_log_group_name(
494    log_group_name: Option<&str>,
495    resource_identifier: Option<&str>,
496) -> Result<String, AwsServiceError> {
497    if let Some(identifier) = resource_identifier {
498        if identifier.starts_with("arn:") {
499            extract_log_group_from_arn(identifier).ok_or_else(|| {
500                AwsServiceError::aws_error(
501                    StatusCode::BAD_REQUEST,
502                    "InvalidParameterException",
503                    format!("Invalid ARN: {identifier}"),
504                )
505            })
506        } else {
507            Ok(identifier.to_string())
508        }
509    } else if let Some(name) = log_group_name {
510        Ok(name.to_string())
511    } else {
512        Err(AwsServiceError::aws_error(
513            StatusCode::BAD_REQUEST,
514            "InvalidParameterException",
515            "Either logGroupName or resourceIdentifier is required",
516        ))
517    }
518}
519
520/// Extract log group name from ARN like "arn:aws:logs:region:account:log-group:name:*"
521pub(crate) fn extract_log_group_from_arn(arn: &str) -> Option<String> {
522    // arn:aws:logs:region:account:log-group:name:*
523    let parts: Vec<&str> = arn.splitn(7, ':').collect();
524    if parts.len() >= 7 && parts[5] == "log-group" {
525        let name = parts[6].strip_suffix(":*").unwrap_or(parts[6]);
526        Some(name.to_string())
527    } else {
528        None
529    }
530}
531
532/// CloudWatch Logs filter pattern matching.
533///
534/// Rules:
535/// - Empty pattern or patterns starting with `{` (JSON patterns) match everything
536/// - Quoted string `"foo bar"` matches the exact substring
537/// - Multiple unquoted words `foo bar` means ALL words must appear anywhere in the message
538/// - Single unquoted word `foo` is a simple substring match
539fn matches_filter_pattern(pattern: &str, message: &str) -> bool {
540    let pattern = pattern.trim();
541
542    // Empty pattern matches everything
543    if pattern.is_empty() {
544        return true;
545    }
546
547    // JSON `{ ... }` (incl. `||`) and array `[ ... ]` patterns use the full
548    // filter-pattern engine, so FilterLogEvents matches them the same way
549    // metric-filter ingest does instead of failing closed.
550    if (pattern.starts_with('{') && pattern.ends_with('}')) || pattern.starts_with('[') {
551        return crate::filter_pattern::matches(pattern, message);
552    }
553
554    // Quoted pattern: exact substring match (handles escaped inner quotes)
555    if pattern.starts_with('"') && pattern.ends_with('"') && pattern.len() >= 2 {
556        let inner = &pattern[1..pattern.len() - 1];
557        // Unescape inner quotes: \"  ->  "
558        let unescaped = inner.replace("\\\"", "\"");
559        return message.contains(&unescaped);
560    }
561
562    // Multiple words: all must be present (AND semantics)
563    let terms = parse_filter_terms(pattern);
564    terms.iter().all(|term| message.contains(term.as_str()))
565}
566
567/// Parse filter pattern terms, respecting quoted strings as single terms.
568fn parse_filter_terms(pattern: &str) -> Vec<String> {
569    let mut terms = Vec::new();
570    let mut chars = pattern.chars().peekable();
571
572    while chars.peek().is_some() {
573        // Skip whitespace
574        while chars.peek().is_some_and(|c| c.is_whitespace()) {
575            chars.next();
576        }
577
578        if chars.peek().is_none() {
579            break;
580        }
581
582        if chars.peek() == Some(&'"') {
583            // Quoted term
584            chars.next(); // consume opening quote
585            let mut term = String::new();
586            loop {
587                match chars.next() {
588                    Some('\\') => {
589                        if let Some(c) = chars.next() {
590                            term.push(c);
591                        }
592                    }
593                    Some('"') => break,
594                    Some(c) => term.push(c),
595                    None => break,
596                }
597            }
598            terms.push(term);
599        } else {
600            // Unquoted term
601            let mut term = String::new();
602            while chars.peek().is_some_and(|c| !c.is_whitespace()) {
603                term.push(chars.next().unwrap());
604            }
605            if !term.is_empty() {
606                terms.push(term);
607            }
608        }
609    }
610
611    terms
612}
613
614#[cfg(test)]
615pub(crate) mod test_helpers {
616    use super::*;
617    use bytes::Bytes;
618    use fakecloud_core::delivery::DeliveryBus;
619    use http::{HeaderMap, Method};
620    use std::collections::HashMap;
621    use std::sync::Arc;
622
623    pub fn make_service() -> LogsService {
624        let state = Arc::new(parking_lot::RwLock::new(
625            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
626        ));
627        let delivery_bus = Arc::new(DeliveryBus::new());
628        LogsService::new(state, delivery_bus)
629    }
630
631    pub fn make_request(
632        action: &str,
633        body: serde_json::Value,
634    ) -> fakecloud_core::service::AwsRequest {
635        fakecloud_core::service::AwsRequest {
636            service: "logs".to_string(),
637            action: action.to_string(),
638            region: "us-east-1".to_string(),
639            account_id: "123456789012".to_string(),
640            request_id: "test-request-id".to_string(),
641            headers: HeaderMap::new(),
642            query_params: HashMap::new(),
643            body: Bytes::from(serde_json::to_vec(&body).unwrap()),
644            body_stream: parking_lot::Mutex::new(None),
645            path_segments: vec![],
646            raw_path: "/".to_string(),
647            raw_query: String::new(),
648            method: Method::POST,
649            is_query_protocol: false,
650            access_key_id: None,
651            principal: None,
652        }
653    }
654
655    pub fn create_group(svc: &LogsService, name: &str) {
656        let req = make_request(
657            "CreateLogGroup",
658            serde_json::json!({ "logGroupName": name }),
659        );
660        svc.create_log_group(&req).unwrap();
661    }
662
663    pub fn create_stream(svc: &LogsService, group: &str, stream: &str) {
664        let req = make_request(
665            "CreateLogStream",
666            serde_json::json!({ "logGroupName": group, "logStreamName": stream }),
667        );
668        svc.create_log_stream(&req).unwrap();
669    }
670
671    pub fn put_events(svc: &LogsService, group: &str, stream: &str, messages: &[&str]) {
672        let now = chrono::Utc::now().timestamp_millis();
673        let events: Vec<serde_json::Value> = messages
674            .iter()
675            .enumerate()
676            .map(|(i, msg)| serde_json::json!({ "timestamp": now + i as i64, "message": msg }))
677            .collect();
678        let req = make_request(
679            "PutLogEvents",
680            serde_json::json!({
681                "logGroupName": group,
682                "logStreamName": stream,
683                "logEvents": events,
684            }),
685        );
686        svc.put_log_events(&req).unwrap();
687    }
688
689    pub fn put_events_at(
690        svc: &LogsService,
691        group: &str,
692        stream: &str,
693        messages: &[&str],
694        timestamp: i64,
695    ) {
696        let events: Vec<serde_json::Value> = messages
697            .iter()
698            .enumerate()
699            .map(
700                |(i, msg)| serde_json::json!({ "timestamp": timestamp + i as i64, "message": msg }),
701            )
702            .collect();
703        let req = make_request(
704            "PutLogEvents",
705            serde_json::json!({
706                "logGroupName": group,
707                "logStreamName": stream,
708                "logEvents": events,
709            }),
710        );
711        svc.put_log_events(&req).unwrap();
712    }
713
714    pub fn put_retention(svc: &LogsService, group: &str, days: i32) {
715        let req = make_request(
716            "PutRetentionPolicy",
717            serde_json::json!({ "logGroupName": group, "retentionInDays": days }),
718        );
719        svc.put_retention_policy(&req).unwrap();
720    }
721
722    // bug-audit 2026-06-27, T1.14: FilterLogEvents now evaluates array `[...]`
723    // patterns through the full engine (positional token match) instead of
724    // failing closed.
725    #[test]
726    fn array_filter_pattern_matches_positionally() {
727        // Bare-name fields match any token in their slot.
728        assert!(matches_filter_pattern("[w1, w2, w3]", "some log message"));
729        // A literal-equality field that doesn't match the token fails.
730        assert!(!matches_filter_pattern(
731            "[w1=ERROR, w2, w3]",
732            "INFO log message"
733        ));
734        // Wrong arity (4 tokens vs 3 fields) doesn't match.
735        assert!(!matches_filter_pattern("[w1, w2, w3]", "a b c d"));
736    }
737
738    // JSON patterns with `||` (OR) now match via the full engine.
739    #[test]
740    fn json_filter_pattern_supports_or() {
741        let msg = r#"{"level":"ERROR","code":500}"#;
742        assert!(matches_filter_pattern(
743            "{ $.level = \"WARN\" || $.code = 500 }",
744            msg
745        ));
746        assert!(!matches_filter_pattern(
747            "{ $.level = \"WARN\" || $.code = 200 }",
748            msg
749        ));
750    }
751
752    /// No snapshot store (memory mode) -> no persist hook for the CFN provisioner.
753    #[test]
754    fn snapshot_hook_is_none_without_store() {
755        let svc = make_service();
756        assert!(svc.snapshot_hook().is_none());
757    }
758
759    /// With a store, the hook is present and invoking it runs the whole-state
760    /// persist path the CloudFormation provisioner uses after mutating logs
761    /// state directly.
762    #[tokio::test]
763    async fn snapshot_hook_fires_with_store() {
764        let store: Arc<dyn fakecloud_persistence::SnapshotStore> =
765            Arc::new(fakecloud_persistence::MemorySnapshotStore::new());
766        let svc = make_service().with_snapshot_store(store);
767        let hook = svc
768            .snapshot_hook()
769            .expect("hook present when a store is set");
770        // Must not panic; exercises the closure and the snapshot save path.
771        hook().await;
772    }
773}