1use std::collections::HashMap;
32use std::sync::RwLock;
33
34use chrono::{DateTime, Duration, Utc};
35use serde::{Deserialize, Serialize};
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum LockMode {
41 Governance,
43 Compliance,
47}
48
49impl LockMode {
50 #[must_use]
52 pub fn as_aws_str(self) -> &'static str {
53 match self {
54 Self::Governance => "GOVERNANCE",
55 Self::Compliance => "COMPLIANCE",
56 }
57 }
58
59 #[must_use]
62 pub fn from_aws_str(s: &str) -> Option<Self> {
63 if s.eq_ignore_ascii_case("GOVERNANCE") {
64 Some(Self::Governance)
65 } else if s.eq_ignore_ascii_case("COMPLIANCE") {
66 Some(Self::Compliance)
67 } else {
68 None
69 }
70 }
71}
72
73#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ObjectLockState {
79 pub mode: Option<LockMode>,
80 pub retain_until: Option<DateTime<Utc>>,
81 pub legal_hold_on: bool,
82}
83
84impl ObjectLockState {
85 #[must_use]
89 pub fn is_locked(&self, now: DateTime<Utc>) -> bool {
90 if self.legal_hold_on {
91 return true;
92 }
93 match (self.mode, self.retain_until) {
94 (Some(_), Some(until)) => until > now,
95 _ => false,
96 }
97 }
98
99 #[must_use]
108 pub fn can_delete(&self, now: DateTime<Utc>, bypass_governance: bool) -> bool {
109 if self.legal_hold_on {
110 return false;
111 }
112 match (self.mode, self.retain_until) {
113 (Some(LockMode::Compliance), Some(until)) if until > now => false,
114 (Some(LockMode::Governance), Some(until)) if until > now => bypass_governance,
115 _ => true,
116 }
117 }
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct BucketObjectLockDefault {
125 pub mode: LockMode,
126 pub retention_days: u32,
127}
128
129#[derive(Debug, Default, Serialize, Deserialize)]
132struct ObjectLockSnapshot {
133 states: Vec<((String, String), ObjectLockState)>,
136 bucket_defaults: HashMap<String, BucketObjectLockDefault>,
137}
138
139#[derive(Debug, Default)]
144pub struct ObjectLockManager {
145 states: RwLock<HashMap<(String, String), ObjectLockState>>,
146 bucket_defaults: RwLock<HashMap<String, BucketObjectLockDefault>>,
147}
148
149impl ObjectLockManager {
150 #[must_use]
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 pub fn set(&self, bucket: &str, key: &str, state: ObjectLockState) {
162 crate::lock_recovery::recover_write(&self.states, "object_lock.states")
163 .insert((bucket.to_owned(), key.to_owned()), state);
164 }
165
166 #[must_use]
168 pub fn get(&self, bucket: &str, key: &str) -> Option<ObjectLockState> {
169 crate::lock_recovery::recover_read(&self.states, "object_lock.states")
170 .get(&(bucket.to_owned(), key.to_owned()))
171 .cloned()
172 }
173
174 pub fn set_legal_hold(&self, bucket: &str, key: &str, on: bool) {
178 let mut guard = crate::lock_recovery::recover_write(&self.states, "object_lock.states");
179 let entry = guard
180 .entry((bucket.to_owned(), key.to_owned()))
181 .or_default();
182 entry.legal_hold_on = on;
183 }
184
185 pub fn set_bucket_default(&self, bucket: &str, default: BucketObjectLockDefault) {
189 crate::lock_recovery::recover_write(&self.bucket_defaults, "object_lock.bucket_defaults")
190 .insert(bucket.to_owned(), default);
191 }
192
193 #[must_use]
195 pub fn bucket_default(&self, bucket: &str) -> Option<BucketObjectLockDefault> {
196 crate::lock_recovery::recover_read(&self.bucket_defaults, "object_lock.bucket_defaults")
197 .get(bucket)
198 .copied()
199 }
200
201 pub fn apply_default_on_put(&self, bucket: &str, key: &str, now: DateTime<Utc>) {
207 let Some(default) = self.bucket_default(bucket) else {
208 return;
209 };
210 let mut guard = crate::lock_recovery::recover_write(&self.states, "object_lock.states");
211 let key_pair = (bucket.to_owned(), key.to_owned());
212 if let Some(existing) = guard.get(&key_pair)
226 && (existing.mode.is_some()
227 || existing.retain_until.is_some()
228 || existing.legal_hold_on)
229 {
230 return;
231 }
232 let retain_until = now + Duration::days(i64::from(default.retention_days));
233 let entry = guard.entry(key_pair).or_default();
234 entry.mode = Some(default.mode);
235 entry.retain_until = Some(retain_until);
236 }
237
238 pub fn clear(&self, bucket: &str, key: &str) {
242 crate::lock_recovery::recover_write(&self.states, "object_lock.states")
243 .remove(&(bucket.to_owned(), key.to_owned()));
244 }
245
246 pub fn to_json(&self) -> Result<String, serde_json::Error> {
249 let states: Vec<((String, String), ObjectLockState)> =
250 crate::lock_recovery::recover_read(&self.states, "object_lock.states")
251 .iter()
252 .map(|(k, v)| (k.clone(), v.clone()))
253 .collect();
254 let bucket_defaults = crate::lock_recovery::recover_read(
255 &self.bucket_defaults,
256 "object_lock.bucket_defaults",
257 )
258 .clone();
259 let snap = ObjectLockSnapshot {
260 states,
261 bucket_defaults,
262 };
263 serde_json::to_string(&snap)
264 }
265
266 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
268 let snap: ObjectLockSnapshot = serde_json::from_str(s)?;
269 let mut states = HashMap::with_capacity(snap.states.len());
270 for (k, v) in snap.states {
271 states.insert(k, v);
272 }
273 Ok(Self {
274 states: RwLock::new(states),
275 bucket_defaults: RwLock::new(snap.bucket_defaults),
276 })
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn now() -> DateTime<Utc> {
285 Utc::now()
286 }
287
288 #[test]
289 fn is_locked_future_retain_until() {
290 let s = ObjectLockState {
291 mode: Some(LockMode::Governance),
292 retain_until: Some(now() + Duration::hours(1)),
293 legal_hold_on: false,
294 };
295 assert!(s.is_locked(now()));
296 }
297
298 #[test]
299 fn is_locked_past_retain_until_is_unlocked() {
300 let s = ObjectLockState {
301 mode: Some(LockMode::Governance),
302 retain_until: Some(now() - Duration::hours(1)),
303 legal_hold_on: false,
304 };
305 assert!(!s.is_locked(now()));
306 }
307
308 #[test]
309 fn compliance_cannot_be_bypassed() {
310 let s = ObjectLockState {
311 mode: Some(LockMode::Compliance),
312 retain_until: Some(now() + Duration::days(7)),
313 legal_hold_on: false,
314 };
315 assert!(!s.can_delete(now(), true));
317 assert!(!s.can_delete(now(), false));
318 }
319
320 #[test]
321 fn governance_can_be_bypassed_with_header() {
322 let s = ObjectLockState {
323 mode: Some(LockMode::Governance),
324 retain_until: Some(now() + Duration::days(7)),
325 legal_hold_on: false,
326 };
327 assert!(
328 s.can_delete(now(), true),
329 "bypass=true should permit delete"
330 );
331 assert!(
332 !s.can_delete(now(), false),
333 "bypass=false should refuse delete"
334 );
335 }
336
337 #[test]
338 fn legal_hold_blocks_delete_independent_of_retention() {
339 let s = ObjectLockState {
341 mode: None,
342 retain_until: None,
343 legal_hold_on: true,
344 };
345 assert!(s.is_locked(now()));
346 assert!(!s.can_delete(now(), true), "legal hold cannot be bypassed");
347 assert!(!s.can_delete(now(), false));
348 }
349
350 #[test]
351 fn legal_hold_overrides_governance_bypass() {
352 let s = ObjectLockState {
355 mode: Some(LockMode::Governance),
356 retain_until: Some(now() + Duration::days(7)),
357 legal_hold_on: true,
358 };
359 assert!(!s.can_delete(now(), true));
360 }
361
362 #[test]
363 fn no_lock_no_block() {
364 let s = ObjectLockState::default();
365 assert!(!s.is_locked(now()));
366 assert!(s.can_delete(now(), false));
367 }
368
369 #[test]
370 fn apply_default_materialises_state_on_first_put() {
371 let m = ObjectLockManager::new();
372 m.set_bucket_default(
373 "b",
374 BucketObjectLockDefault {
375 mode: LockMode::Governance,
376 retention_days: 30,
377 },
378 );
379 let now = now();
380 m.apply_default_on_put("b", "k", now);
381 let state = m.get("b", "k").expect("state must be materialised");
382 assert_eq!(state.mode, Some(LockMode::Governance));
383 let until = state.retain_until.expect("retain_until must be set");
384 let target = now + Duration::days(30);
385 let diff = (until - target).num_seconds().abs();
387 assert!(diff <= 1, "retain_until off by {diff}s");
388 }
389
390 #[test]
391 fn apply_default_does_not_overwrite_existing_retention() {
392 let m = ObjectLockManager::new();
393 let custom_until = now() + Duration::days(365);
394 m.set(
395 "b",
396 "k",
397 ObjectLockState {
398 mode: Some(LockMode::Compliance),
399 retain_until: Some(custom_until),
400 legal_hold_on: false,
401 },
402 );
403 m.set_bucket_default(
404 "b",
405 BucketObjectLockDefault {
406 mode: LockMode::Governance,
407 retention_days: 1,
408 },
409 );
410 m.apply_default_on_put("b", "k", now());
411 let state = m.get("b", "k").unwrap();
412 assert_eq!(state.mode, Some(LockMode::Compliance));
414 assert_eq!(state.retain_until, Some(custom_until));
415 }
416
417 #[test]
418 fn apply_default_no_op_without_bucket_default() {
419 let m = ObjectLockManager::new();
420 m.apply_default_on_put("b", "k", now());
421 assert!(m.get("b", "k").is_none());
422 }
423
424 #[test]
425 fn set_legal_hold_creates_state_when_missing() {
426 let m = ObjectLockManager::new();
427 m.set_legal_hold("b", "k", true);
428 let s = m.get("b", "k").expect("state created");
429 assert!(s.legal_hold_on);
430 assert!(s.mode.is_none());
431 assert!(s.retain_until.is_none());
432 m.set_legal_hold("b", "k", false);
433 let s2 = m.get("b", "k").unwrap();
434 assert!(!s2.legal_hold_on);
435 }
436
437 #[test]
438 fn snapshot_roundtrip() {
439 let m = ObjectLockManager::new();
440 m.set(
441 "b1",
442 "k1",
443 ObjectLockState {
444 mode: Some(LockMode::Compliance),
445 retain_until: Some(Utc::now() + Duration::days(10)),
446 legal_hold_on: true,
447 },
448 );
449 m.set_bucket_default(
450 "b1",
451 BucketObjectLockDefault {
452 mode: LockMode::Governance,
453 retention_days: 7,
454 },
455 );
456 let json = m.to_json().expect("to_json");
457 let m2 = ObjectLockManager::from_json(&json).expect("from_json");
458 let s = m2.get("b1", "k1").expect("state survives roundtrip");
459 assert_eq!(s.mode, Some(LockMode::Compliance));
460 assert!(s.legal_hold_on);
461 let d = m2.bucket_default("b1").expect("default survives roundtrip");
462 assert_eq!(d.mode, LockMode::Governance);
463 assert_eq!(d.retention_days, 7);
464 }
465
466 #[test]
467 fn lock_mode_aws_string_roundtrip() {
468 assert_eq!(
469 LockMode::from_aws_str(LockMode::Governance.as_aws_str()),
470 Some(LockMode::Governance)
471 );
472 assert_eq!(
473 LockMode::from_aws_str(LockMode::Compliance.as_aws_str()),
474 Some(LockMode::Compliance)
475 );
476 assert_eq!(
477 LockMode::from_aws_str("governance"),
478 Some(LockMode::Governance)
479 );
480 assert!(LockMode::from_aws_str("nope").is_none());
481 }
482
483 #[test]
484 fn clear_removes_state() {
485 let m = ObjectLockManager::new();
486 m.set(
487 "b",
488 "k",
489 ObjectLockState {
490 mode: Some(LockMode::Governance),
491 retain_until: Some(Utc::now() + Duration::days(1)),
492 legal_hold_on: false,
493 },
494 );
495 assert!(m.get("b", "k").is_some());
496 m.clear("b", "k");
497 assert!(m.get("b", "k").is_none());
498 }
499
500 #[test]
505 fn object_lock_to_json_after_panic_recovers_via_poison() {
506 let m = ObjectLockManager::new();
507 m.set(
508 "b",
509 "k",
510 ObjectLockState {
511 mode: Some(LockMode::Compliance),
512 retain_until: Some(Utc::now() + Duration::days(7)),
513 legal_hold_on: false,
514 },
515 );
516 let m = std::sync::Arc::new(m);
517 let m_cl = std::sync::Arc::clone(&m);
518 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
519 let mut g = m_cl.states.write().expect("clean lock");
520 g.entry(("b".into(), "k2".into())).or_default();
521 panic!("force-poison");
522 }));
523 assert!(
524 m.states.is_poisoned(),
525 "write panic must poison states lock"
526 );
527 let json = m.to_json().expect("to_json after poison must succeed");
528 let m2 = ObjectLockManager::from_json(&json).expect("from_json");
529 assert!(
530 m2.get("b", "k").is_some(),
531 "recovered snapshot keeps original entry"
532 );
533 }
534}