1use crate::{error::ErrorData, Queue, ResourceRef, Result, Stack, Storage, StorageNotification};
4use alien_error::{AlienError, Context, IntoAlienError};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7
8pub const GCS_SUFFIXES_ATTRIBUTE: &str = "storageEventSuffixes";
11
12pub const GCS_RULE_ATTRIBUTE: &str = "storageNotificationRule";
14
15const GCS_MAX_SUFFIX_ATTRIBUTES: usize = 4;
17
18const 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
31pub 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct GcsNotificationConfig<'a> {
47 pub key: String,
50 pub queue: &'a ResourceRef,
52 pub prefix: Option<&'a str>,
54 pub suffix_attributes: Vec<(String, String)>,
57}
58
59pub 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 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
142pub 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
163pub 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
178pub 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
199pub 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
215pub fn aws_notification_id(prefix: &str, storage_id: &str, index: usize) -> String {
218 format!("{}{index}", aws_notification_id_prefix(prefix, storage_id))
219}
220
221pub 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
227pub 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 #[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 type Decoded<'a> = (&'a str, Option<&'a str>, Option<Vec<String>>);
305
306 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 #[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 #[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}