Skip to main content

alien_core/
storage_notifications.rs

1//! Shared notification translation for setup generators and live controllers.
2
3use crate::{error::ErrorData, Queue, ResourceRef, Result, Stack, Storage, StorageNotification};
4use alien_error::{AlienError, Context, IntoAlienError};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7
8/// Custom GCS notification attributes `storageEventSuffixes0`, `storageEventSuffixes1`, …
9/// whose values, concatenated in order, form a JSON array of the config's literal suffixes.
10pub const GCS_SUFFIXES_ATTRIBUTE: &str = "storageEventSuffixes";
11
12/// Stable config identity for additive GCS notification reconciliation.
13pub const GCS_RULE_ATTRIBUTE: &str = "storageNotificationRule";
14
15/// GCS accepts at most 5 custom attributes per notification config; one is the rule.
16const GCS_MAX_SUFFIX_ATTRIBUTES: usize = 4;
17
18/// GCS rejects custom attribute values longer than 1024 characters, and Pub/Sub attribute
19/// values are limited to 1024 bytes. Chunks are cut at character boundaries by bytes.
20const GCS_ATTRIBUTE_VALUE_BYTES: usize = 1024;
21
22fn short_hash(parts: &[&str]) -> String {
23    let mut digest = Sha256::new();
24    for part in parts {
25        digest.update(part.as_bytes());
26        digest.update([0]);
27    }
28    format!("{:x}", digest.finalize())[..16].to_string()
29}
30
31/// Stable identity of what a rule delivers (queue, prefix, suffix), independent of its
32/// position, so adding or removing another rule never replaces this one's subscription.
33pub fn notification_rule_key(rule: &StorageNotification) -> String {
34    short_hash(&[
35        "rule",
36        &rule.queue.id,
37        rule.prefix.as_deref().unwrap_or_default(),
38        rule.suffix.as_deref().unwrap_or_default(),
39    ])
40}
41
42/// One GCS notification config: every rule of a Storage with the same queue and prefix.
43/// GCS filters by prefix only, so one config per rule would publish each object under a
44/// shared prefix once per rule. The consumer checks the suffixes instead.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct GcsNotificationConfig<'a> {
47    /// Stable identity of the group (queue and prefix), independent of rule positions and
48    /// suffixes: a changed suffix set replaces only this group's config.
49    pub key: String,
50    /// Queue the config publishes to.
51    pub queue: &'a ResourceRef,
52    /// `object_name_prefix`, if any.
53    pub prefix: Option<&'a str>,
54    /// Suffix attributes; empty when a rule of the group has no suffix and so matches
55    /// every object under the prefix (rules can't overlap, so it is then the only rule).
56    pub suffix_attributes: Vec<(String, String)>,
57}
58
59/// GCS notification configs for a Storage's rules, in order of first appearance.
60pub fn gcs_notification_configs(storage: &Storage) -> Result<Vec<GcsNotificationConfig<'_>>> {
61    let mut groups: Vec<(&ResourceRef, Option<&str>, Vec<&str>, bool)> = Vec::new();
62    for rule in &storage.notifications {
63        let prefix = rule.prefix.as_deref();
64        let index = match groups
65            .iter()
66            .position(|(queue, other, _, _)| queue.id == rule.queue.id && *other == prefix)
67        {
68            Some(index) => index,
69            None => {
70                groups.push((&rule.queue, prefix, Vec::new(), false));
71                groups.len() - 1
72            }
73        };
74        match rule.suffix.as_deref() {
75            Some(suffix) => groups[index].2.push(suffix),
76            None => groups[index].3 = true,
77        }
78    }
79    groups
80        .into_iter()
81        .map(|(queue, prefix, mut suffixes, unfiltered)| {
82            // Sorted, so reordering rules leaves the group's config unchanged.
83            suffixes.sort_unstable();
84            let suffix_attributes = if unfiltered {
85                Vec::new()
86            } else {
87                gcs_suffix_attributes(&suffixes).context(ErrorData::GenericError {
88                    message: format!(
89                        "Storage '{}' notification suffixes for queue '{}' do not fit in GCS attributes",
90                        storage.id, queue.id
91                    ),
92                })?
93            };
94            Ok(GcsNotificationConfig {
95                key: short_hash(&["gcs", &queue.id, prefix.unwrap_or_default()]),
96                queue,
97                prefix,
98                suffix_attributes,
99            })
100        })
101        .collect()
102}
103
104fn gcs_suffix_attributes(suffixes: &[&str]) -> Result<Vec<(String, String)>> {
105    let encoded =
106        serde_json::to_string(suffixes)
107            .into_alien_error()
108            .context(ErrorData::GenericError {
109                message: "Encode notification suffixes".to_string(),
110            })?;
111    let mut chunks = Vec::new();
112    let mut rest = encoded.as_str();
113    while !rest.is_empty() {
114        let mut end = rest.len().min(GCS_ATTRIBUTE_VALUE_BYTES);
115        while !rest.is_char_boundary(end) {
116            end -= 1;
117        }
118        chunks.push(&rest[..end]);
119        rest = &rest[end..];
120    }
121    if chunks.len() > GCS_MAX_SUFFIX_ATTRIBUTES {
122        return Err(AlienError::new(ErrorData::GenericError {
123            message: format!(
124                "{} encoded bytes need {} attributes; GCS allows {GCS_MAX_SUFFIX_ATTRIBUTES}",
125                encoded.len(),
126                chunks.len()
127            ),
128        }));
129    }
130    Ok(chunks
131        .into_iter()
132        .enumerate()
133        .map(|(index, chunk)| {
134            (
135                format!("{GCS_SUFFIXES_ATTRIBUTE}{index}"),
136                chunk.to_string(),
137            )
138        })
139        .collect())
140}
141
142/// The literal suffixes a GCS notification's attributes carry, or `None` when they carry
143/// none (every object under the config's prefix matches).
144pub fn gcs_suffixes(attributes: &HashMap<String, String>) -> Result<Option<Vec<String>>> {
145    let mut encoded = String::new();
146    let mut index = 0;
147    while let Some(chunk) = attributes.get(&format!("{GCS_SUFFIXES_ATTRIBUTE}{index}")) {
148        encoded.push_str(chunk);
149        index += 1;
150    }
151    if index == 0 {
152        return Ok(None);
153    }
154    serde_json::from_str(&encoded)
155        .map(Some)
156        .into_alien_error()
157        .context(ErrorData::GenericError {
158            message: "Storage notification suffix attributes are not a JSON string array"
159                .to_string(),
160        })
161}
162
163/// Whether this queue owns an ingress grant for storage notifications in its stack.
164pub fn queue_receives_storage_notifications(stack: &Stack, queue_id: &str) -> bool {
165    stack.resources().any(|(_, entry)| {
166        entry
167            .config
168            .downcast_ref::<Storage>()
169            .is_some_and(|storage| {
170                storage.notifications.iter().any(|notification| {
171                    notification.queue.resource_type == Queue::RESOURCE_TYPE
172                        && notification.queue.id == queue_id
173                })
174            })
175    })
176}
177
178/// Ids of the Storages in the stack that notify this queue, sorted for deterministic output.
179pub fn storages_notifying_queue(stack: &Stack, queue_id: &str) -> Vec<String> {
180    let mut storages: Vec<String> = stack
181        .resources()
182        .filter(|(_, entry)| {
183            entry
184                .config
185                .downcast_ref::<Storage>()
186                .is_some_and(|storage| {
187                    storage.notifications.iter().any(|notification| {
188                        notification.queue.resource_type == Queue::RESOURCE_TYPE
189                            && notification.queue.id == queue_id
190                    })
191                })
192        })
193        .map(|(id, _)| id.clone())
194        .collect();
195    storages.sort();
196    storages
197}
198
199/// Whether one object key can match both rules. Rules on one Storage must not overlap:
200/// S3 rejects such a configuration ("Configuration is ambiguously defined"), so the
201/// portable declaration rejects it everywhere. Prefixes overlap when either is absent or
202/// one starts with the other; suffixes when either is absent or one ends with the other.
203pub fn notifications_overlap(a: &StorageNotification, b: &StorageNotification) -> bool {
204    let prefixes = match (a.prefix.as_deref(), b.prefix.as_deref()) {
205        (Some(a), Some(b)) => a.starts_with(b) || b.starts_with(a),
206        _ => true,
207    };
208    let suffixes = match (a.suffix.as_deref(), b.suffix.as_deref()) {
209        (Some(a), Some(b)) => a.ends_with(b) || b.ends_with(a),
210        _ => true,
211    };
212    prefixes && suffixes
213}
214
215/// Id of a rule's S3 queue configuration. The stack prefix and a hash of the Storage's
216/// identity let the owning controller recognise its own entries among everyone else's.
217pub fn aws_notification_id(prefix: &str, storage_id: &str, index: usize) -> String {
218    format!("{}{index}", aws_notification_id_prefix(prefix, storage_id))
219}
220
221/// Every [`aws_notification_id`] of one Storage starts with this.
222pub fn aws_notification_id_prefix(prefix: &str, storage_id: &str) -> String {
223    let hash = format!("{:x}", Sha256::digest(storage_id.as_bytes()));
224    format!("{prefix}-sn-{}-", &hash[..16])
225}
226
227/// `Sid` of the queue policy statement that lets S3 deliver a stack's notifications.
228pub const AWS_QUEUE_INGRESS_SID: &str = "StorageNotificationIngress";
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::{Queue, ResourceRef};
234    use std::collections::HashSet;
235
236    fn notification(prefix: Option<&str>, suffix: Option<&str>) -> StorageNotification {
237        StorageNotification::builder()
238            .queue(ResourceRef::new(Queue::RESOURCE_TYPE, "work"))
239            .maybe_prefix(prefix.map(str::to_string))
240            .maybe_suffix(suffix.map(str::to_string))
241            .build()
242    }
243
244    fn filters() -> Vec<Option<String>> {
245        let mut all = vec![None];
246        let mut level = vec![String::new()];
247        for _ in 0..3 {
248            let next: Vec<_> = level
249                .iter()
250                .flat_map(|value| ['a', 'b', 'é'].map(|character| format!("{value}{character}")))
251                .collect();
252            all.extend(next.iter().cloned().map(Some));
253            level = next;
254        }
255        all
256    }
257
258    fn keys() -> Vec<String> {
259        let mut all = vec![String::new()];
260        let mut level = vec![String::new()];
261        for _ in 0..6 {
262            let next: Vec<_> = level
263                .iter()
264                .flat_map(|key| ['a', 'b', 'é'].map(|character| format!("{key}{character}")))
265                .collect();
266            all.extend(next.clone());
267            level = next;
268        }
269        all
270    }
271
272    fn matches(rule: &StorageNotification, key: &str) -> bool {
273        rule.matches_key(key)
274    }
275
276    /// Rules overlap exactly when they share a key that S3 would have to route twice.
277    /// Keys are long enough (6 characters vs filters of at most 3) that every
278    /// overlapping pair has a witness key.
279    #[test]
280    fn overlap_equals_existence_of_a_key_matching_both_rules() {
281        let keys = keys();
282        let filters = filters();
283        for (ap, asf) in filters
284            .iter()
285            .flat_map(|p| filters.iter().map(move |s| (p, s)))
286        {
287            let a = notification(ap.as_deref(), asf.as_deref());
288            for (bp, bsf) in filters
289                .iter()
290                .flat_map(|p| filters.iter().map(move |s| (p, s)))
291            {
292                let b = notification(bp.as_deref(), bsf.as_deref());
293                let witnessed = keys.iter().any(|key| matches(&a, key) && matches(&b, key));
294                assert_eq!(notifications_overlap(&a, &b), witnessed, "a={a:?} b={b:?}");
295            }
296        }
297    }
298
299    fn storage(rules: Vec<StorageNotification>) -> Storage {
300        Storage::new("uploads".into()).notifications(rules).build()
301    }
302
303    /// A GCS config as the consumer sees it: queue, prefix and decoded suffixes.
304    type Decoded<'a> = (&'a str, Option<&'a str>, Option<Vec<String>>);
305
306    /// Queues of the configs that deliver `key`: GCS filters by prefix, the consumer by the
307    /// decoded suffixes.
308    fn delivered_to<'a>(configs: &[Decoded<'a>], key: &str) -> Vec<&'a str> {
309        configs
310            .iter()
311            .filter(|(_, prefix, suffixes)| {
312                prefix.is_none_or(|prefix| key.starts_with(prefix))
313                    && suffixes
314                        .as_ref()
315                        .is_none_or(|suffixes| suffixes.iter().any(|suffix| key.ends_with(suffix)))
316            })
317            .map(|(queue, _, _)| *queue)
318            .collect()
319    }
320
321    fn decoded(storage: &Storage) -> Vec<Decoded<'_>> {
322        gcs_notification_configs(storage)
323            .unwrap()
324            .into_iter()
325            .map(|config| {
326                let attributes: HashMap<_, _> = config.suffix_attributes.into_iter().collect();
327                (
328                    config.queue.id.as_str(),
329                    config.prefix,
330                    gcs_suffixes(&attributes).unwrap(),
331                )
332            })
333            .collect()
334    }
335
336    /// For every pair of non-overlapping rules: rules with one queue and prefix share a
337    /// config, and every key is delivered exactly once if a rule matches it, else never.
338    #[test]
339    fn grouped_gcs_configs_deliver_exactly_the_keys_each_rule_matches() {
340        let filters: Vec<_> = filters()
341            .into_iter()
342            .filter(|filter| {
343                filter
344                    .as_ref()
345                    .is_none_or(|value| value.chars().count() <= 2)
346            })
347            .collect();
348        let keys: Vec<_> = keys()
349            .into_iter()
350            .filter(|key| key.chars().count() <= 4)
351            .collect();
352        let rules: Vec<_> = filters
353            .iter()
354            .flat_map(|prefix| {
355                filters
356                    .iter()
357                    .map(move |suffix| notification(prefix.as_deref(), suffix.as_deref()))
358            })
359            .collect();
360        for (index, a) in rules.iter().enumerate() {
361            for b in &rules[index + 1..] {
362                if notifications_overlap(a, b) {
363                    continue;
364                }
365                let storage = storage(vec![a.clone(), b.clone()]);
366                let configs = decoded(&storage);
367                assert_eq!(configs.len(), if a.prefix == b.prefix { 1 } else { 2 });
368                for key in &keys {
369                    let expected = [a, b].iter().filter(|rule| matches(rule, key)).count();
370                    assert_eq!(
371                        delivered_to(&configs, key).len(),
372                        expected,
373                        "a={a:?} b={b:?} key={key:?}"
374                    );
375                }
376            }
377        }
378    }
379
380    #[test]
381    fn gcs_configs_split_by_queue_and_keep_unfiltered_rules_unfiltered() {
382        let rule = |queue: &str, prefix: &str, suffix: Option<&str>| {
383            StorageNotification::builder()
384                .queue(ResourceRef::new(Queue::RESOURCE_TYPE, queue))
385                .prefix(prefix.into())
386                .maybe_suffix(suffix.map(str::to_string))
387                .build()
388        };
389        let storage = storage(vec![
390            rule("work", "logs/", Some(".json")),
391            rule("audit", "logs/", Some(".csv")),
392            rule("work", "logs/", Some(".gz")),
393            rule("work", "raw/", None),
394        ]);
395        let configs = decoded(&storage);
396        assert_eq!(
397            configs,
398            [
399                (
400                    "work",
401                    Some("logs/"),
402                    Some(vec![".gz".into(), ".json".into()])
403                ),
404                ("audit", Some("logs/"), Some(vec![".csv".into()])),
405                ("work", Some("raw/"), None),
406            ]
407        );
408        assert_eq!(delivered_to(&configs, "logs/a.json"), ["work"]);
409        assert_eq!(delivered_to(&configs, "logs/a.csv"), ["audit"]);
410        assert!(delivered_to(&configs, "logs/a.txt").is_empty());
411        assert_eq!(delivered_to(&configs, "raw/anything"), ["work"]);
412    }
413
414    /// The largest suffix set the portable limits allow (10 rules, 128-byte suffixes) must
415    /// fit the GCS limits measured on real GCS: at most 5 custom attributes per config
416    /// (one is the rule) and values of at most 1024 characters.
417    #[test]
418    fn largest_allowed_suffix_set_fits_gcs_attribute_limits_and_round_trips() {
419        let suffixes: Vec<String> = (0..10)
420            .map(|index| {
421                let mut suffix = index.to_string();
422                for character in ['"', '\\', '*', 'é'].iter().cycle() {
423                    if suffix.len() + character.len_utf8() > 128 {
424                        break;
425                    }
426                    suffix.push(*character);
427                }
428                suffix
429            })
430            .collect();
431        assert!(suffixes.iter().all(|suffix| suffix.len() >= 127));
432        let borrowed: Vec<&str> = suffixes.iter().map(String::as_str).collect();
433        let attributes = gcs_suffix_attributes(&borrowed).unwrap();
434        assert!(attributes.len() <= GCS_MAX_SUFFIX_ATTRIBUTES);
435        assert!(
436            attributes.len() > 1,
437            "the worst case must exercise chunking"
438        );
439        for (_, value) in &attributes {
440            assert!(value.len() <= GCS_ATTRIBUTE_VALUE_BYTES);
441        }
442        let attributes: HashMap<_, _> = attributes.into_iter().collect();
443        assert_eq!(gcs_suffixes(&attributes).unwrap(), Some(suffixes));
444        assert_eq!(gcs_suffixes(&HashMap::new()).unwrap(), None);
445    }
446
447    #[test]
448    fn ids_are_distinct_per_storage_and_rule_and_carry_the_storage_prefix() {
449        let mut ids = HashSet::new();
450        for storage in ["source", "second", "long-resource-identity"] {
451            let prefix = aws_notification_id_prefix("stack", storage);
452            for index in 0..10 {
453                let id = aws_notification_id("stack", storage, index);
454                assert!(id.starts_with(&prefix));
455                assert!(ids.insert(id));
456            }
457        }
458        assert!(!aws_notification_id("stack", "source", 1)
459            .starts_with(&aws_notification_id_prefix("stack", "second")));
460    }
461}