1use crate::{
2 error::{ErrorData, Result},
3 resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef},
4 ResourceType,
5};
6use alien_error::AlienError;
7use bon::Builder;
8use serde::{Deserialize, Serialize};
9use std::{any::Any, fmt::Debug};
10
11pub const AWS_ABORT_INCOMPLETE_UPLOAD_DAYS: i32 = 7;
16
17pub const AWS_LIFECYCLE_RULE_LIMIT: usize = 1000;
20
21pub const AWS_ABORT_INCOMPLETE_UPLOAD_RULE_ID: &str = "abort-incomplete-uploads";
24
25pub const AWS_SIGNED_UPLOAD_HEADERS: &[&str] = &[
29 "Content-Type",
30 "Content-MD5",
31 "Content-Disposition",
32 "Content-Encoding",
33 "Cache-Control",
34 "x-amz-*",
35];
36
37pub const GCP_SIGNED_UPLOAD_HEADERS: &[&str] = &[
41 "Content-Type",
42 "Content-MD5",
43 "Content-Disposition",
44 "Content-Encoding",
45 "Cache-Control",
46 "x-goog-content-length-range",
47 "ETag",
48];
49
50pub const AZURE_SIGNED_UPLOAD_HEADERS: &[&str] = &["Content-Type", "Content-MD5", "x-ms-*"];
55
56pub const SIGNED_UPLOAD_EXPOSED_HEADERS: &[&str] = &["ETag"];
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct LifecycleRule {
64 pub days: u32,
66 #[serde(default)]
68 pub prefix: Option<String>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "camelCase")]
75pub enum StorageNotificationEvent {
76 Created,
78}
79
80fn default_notification_events() -> Vec<StorageNotificationEvent> {
81 vec![StorageNotificationEvent::Created]
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct StorageNotification {
91 pub queue: ResourceRef,
93 #[serde(default = "default_notification_events")]
95 #[builder(default = default_notification_events())]
96 pub events: Vec<StorageNotificationEvent>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub prefix: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub suffix: Option<String>,
103}
104
105impl StorageNotification {
106 pub fn matches_key(&self, key: &str) -> bool {
108 self.prefix
109 .as_ref()
110 .is_none_or(|prefix| key.starts_with(prefix))
111 && self
112 .suffix
113 .as_ref()
114 .is_none_or(|suffix| key.ends_with(suffix))
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
120#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122#[builder(start_fn = new)]
123pub struct Storage {
124 #[builder(start_fn)]
127 pub id: String,
128
129 #[serde(default)]
132 #[builder(default)]
133 pub public_read: bool,
134
135 #[serde(default)]
138 #[builder(default)]
139 pub versioning: bool,
140
141 #[serde(default)]
144 #[builder(default)]
145 pub lifecycle_rules: Vec<LifecycleRule>,
146
147 #[serde(default)]
154 #[builder(default)]
155 pub cors_allowed_origins: Vec<String>,
156
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 #[builder(default)]
172 pub cors_upload_origins: Vec<String>,
173
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 #[builder(default)]
178 #[cfg_attr(feature = "openapi", schema(max_items = 10))]
179 pub notifications: Vec<StorageNotification>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub encryption_key: Option<ResourceRef>,
184}
185
186impl Storage {
187 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("storage");
189
190 pub fn id(&self) -> &str {
192 &self.id
193 }
194
195 pub fn has_cors(&self) -> bool {
197 !self.cors_allowed_origins.is_empty() || !self.cors_upload_origins.is_empty()
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
204#[serde(rename_all = "camelCase")]
205pub struct StorageOutputs {
206 pub bucket_name: String,
208 }
210
211impl ResourceOutputsDefinition for StorageOutputs {
212 fn get_resource_type(&self) -> ResourceType {
213 Storage::RESOURCE_TYPE.clone()
214 }
215
216 fn as_any(&self) -> &dyn Any {
217 self
218 }
219
220 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
221 Box::new(self.clone())
222 }
223
224 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
225 other.as_any().downcast_ref::<StorageOutputs>() == Some(self)
226 }
227
228 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
229 serde_json::to_value(self)
230 }
231}
232
233impl ResourceDefinition for Storage {
235 fn get_resource_type(&self) -> ResourceType {
236 Self::RESOURCE_TYPE
237 }
238
239 fn id(&self) -> &str {
240 &self.id
241 }
242
243 fn replace_after_failed_create_is_safe(&self) -> bool {
244 false
246 }
247
248 fn get_dependencies(&self) -> Vec<ResourceRef> {
249 let mut dependencies: Vec<_> = self.encryption_key.iter().cloned().collect();
250 for notification in &self.notifications {
251 if !dependencies.contains(¬ification.queue) {
252 dependencies.push(notification.queue.clone());
253 }
254 }
255 dependencies
256 }
257
258 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
259 let new_storage = new_config
261 .as_any()
262 .downcast_ref::<Storage>()
263 .ok_or_else(|| {
264 AlienError::new(ErrorData::UnexpectedResourceType {
265 resource_id: self.id.clone(),
266 expected: Self::RESOURCE_TYPE,
267 actual: new_config.get_resource_type(),
268 })
269 })?;
270
271 if self.id != new_storage.id {
272 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
273 resource_id: self.id.clone(),
274 reason: "the 'id' field is immutable".to_string(),
275 }));
276 }
277 if self.encryption_key != new_storage.encryption_key {
278 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
279 resource_id: self.id.clone(),
280 reason: "the 'encryptionKey' field is immutable once the resource exists"
281 .to_string(),
282 }));
283 }
284 Ok(())
285 }
286
287 fn as_any(&self) -> &dyn Any {
288 self
289 }
290
291 fn as_any_mut(&mut self) -> &mut dyn Any {
292 self
293 }
294
295 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
296 Box::new(self.clone())
297 }
298
299 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
300 other.as_any().downcast_ref::<Storage>() == Some(self)
301 }
302
303 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
304 serde_json::to_value(self)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn encryption_key_is_an_implicit_dependency() {
314 let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "customer-key");
315 let storage = Storage::new("customer-data".to_string())
316 .encryption_key(key.clone())
317 .build();
318
319 assert_eq!(storage.get_dependencies(), vec![key]);
320 }
321
322 #[test]
323 fn notifications_preserve_existing_serialization_and_deduplicate_dependencies() {
324 let original: Storage = serde_json::from_value(serde_json::json!({
325 "id": "objects", "publicRead": false, "versioning": false,
326 "lifecycleRules": [], "corsAllowedOrigins": []
327 }))
328 .unwrap();
329 assert!(original.notifications.is_empty());
330 assert_eq!(
331 serde_json::to_value(&original).unwrap(),
332 serde_json::json!({
333 "id": "objects", "publicRead": false, "versioning": false,
334 "lifecycleRules": [], "corsAllowedOrigins": []
335 })
336 );
337 let key = ResourceRef::new(crate::Key::RESOURCE_TYPE, "key");
338 let queue = ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work");
339 let rule = StorageNotification::builder().queue(queue.clone()).build();
340 let storage = Storage::new("objects".to_string())
341 .encryption_key(key.clone())
342 .notifications(vec![rule.clone(), rule])
343 .build();
344 assert_eq!(storage.get_dependencies(), vec![key, queue]);
345 let encoded = serde_json::to_value(&storage).unwrap();
346 assert_eq!(
347 encoded["notifications"][0]["events"],
348 serde_json::json!(["created"])
349 );
350 assert_eq!(serde_json::from_value::<Storage>(encoded).unwrap(), storage);
351 }
352
353 #[test]
354 fn upload_origins_round_trip_and_stay_out_of_existing_serialization() {
355 let declared = serde_json::json!({
356 "id": "uploads", "publicRead": false, "versioning": false,
357 "lifecycleRules": [], "corsAllowedOrigins": [],
358 "corsUploadOrigins": ["https://app.example.com"]
359 });
360 let storage: Storage = serde_json::from_value(declared.clone())
361 .expect("a storage declaration may allow signed browser uploads");
362 assert_eq!(storage.cors_upload_origins, ["https://app.example.com"]);
363 assert!(storage.has_cors());
364 assert_eq!(serde_json::to_value(&storage).unwrap(), declared);
365
366 let read_only = Storage::new("objects".to_string())
367 .cors_allowed_origins(vec!["https://app.example.com".into()])
368 .build();
369 assert!(read_only.has_cors());
370 assert!(serde_json::to_value(&read_only)
371 .unwrap()
372 .get("corsUploadOrigins")
373 .is_none());
374 assert!(!Storage::new("plain".to_string()).build().has_cors());
375 }
376
377 #[test]
378 fn notification_filters_are_literal_case_sensitive_and_combined() {
379 let rule = StorageNotification::builder()
380 .queue(ResourceRef::new(crate::Queue::RESOURCE_TYPE, "work"))
381 .prefix("in/*/é/".to_string())
382 .suffix(".json".to_string())
383 .build();
384 assert!(rule.matches_key("in/*/é/object.json"));
385 for key in [
386 "in/a/é/object.json",
387 "in/*/e/object.json",
388 "in/*/é/object.JSON",
389 "other/object.json",
390 "in/*/é/object.json.tmp",
391 ] {
392 assert!(!rule.matches_key(key), "{key}");
393 }
394 let mut unfiltered = rule.clone();
395 unfiltered.prefix = None;
396 unfiltered.suffix = None;
397 assert!(unfiltered.matches_key("anything"));
398 unfiltered.suffix = Some(".json".to_string());
399 assert!(unfiltered.matches_key("any/path.json"));
400 assert!(!unfiltered.matches_key("any/path.txt"));
401 unfiltered.prefix = Some("same".to_string());
402 unfiltered.suffix = Some("same".to_string());
403 assert!(unfiltered.matches_key("same"));
404 }
405
406 #[test]
407 fn notification_events_default_to_created_and_reject_unknown_events() {
408 let rule: StorageNotification = serde_json::from_value(serde_json::json!({
409 "queue": { "type": "queue", "id": "work" }
410 }))
411 .unwrap();
412 assert_eq!(rule.events, vec![StorageNotificationEvent::Created]);
413 assert!(
414 serde_json::from_value::<StorageNotification>(serde_json::json!({
415 "queue": { "type": "queue", "id": "work" }, "events": ["deleted"]
416 }))
417 .is_err()
418 );
419 }
420}