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 let retention_active = match existing.retain_until {
227 Some(until) => until > now,
228 None => false,
229 };
230 let mode_active = existing.mode.is_some() && retention_active;
231 if mode_active || retention_active || existing.legal_hold_on {
232 return;
233 }
234 }
235 let retain_until = now + Duration::days(i64::from(default.retention_days));
236 let entry = guard.entry(key_pair).or_default();
237 entry.mode = Some(default.mode);
238 entry.retain_until = Some(retain_until);
239 }
240
241 pub fn clear(&self, bucket: &str, key: &str) {
245 crate::lock_recovery::recover_write(&self.states, "object_lock.states")
246 .remove(&(bucket.to_owned(), key.to_owned()));
247 }
248
249 pub fn to_json(&self) -> Result<String, serde_json::Error> {
252 let states: Vec<((String, String), ObjectLockState)> =
253 crate::lock_recovery::recover_read(&self.states, "object_lock.states")
254 .iter()
255 .map(|(k, v)| (k.clone(), v.clone()))
256 .collect();
257 let bucket_defaults = crate::lock_recovery::recover_read(
258 &self.bucket_defaults,
259 "object_lock.bucket_defaults",
260 )
261 .clone();
262 let snap = ObjectLockSnapshot {
263 states,
264 bucket_defaults,
265 };
266 serde_json::to_string(&snap)
267 }
268
269 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
271 let snap: ObjectLockSnapshot = serde_json::from_str(s)?;
272 let mut states = HashMap::with_capacity(snap.states.len());
273 for (k, v) in snap.states {
274 states.insert(k, v);
275 }
276 Ok(Self {
277 states: RwLock::new(states),
278 bucket_defaults: RwLock::new(snap.bucket_defaults),
279 })
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn now() -> DateTime<Utc> {
288 Utc::now()
289 }
290
291 #[test]
292 fn is_locked_future_retain_until() {
293 let s = ObjectLockState {
294 mode: Some(LockMode::Governance),
295 retain_until: Some(now() + Duration::hours(1)),
296 legal_hold_on: false,
297 };
298 assert!(s.is_locked(now()));
299 }
300
301 #[test]
302 fn is_locked_past_retain_until_is_unlocked() {
303 let s = ObjectLockState {
304 mode: Some(LockMode::Governance),
305 retain_until: Some(now() - Duration::hours(1)),
306 legal_hold_on: false,
307 };
308 assert!(!s.is_locked(now()));
309 }
310
311 #[test]
312 fn compliance_cannot_be_bypassed() {
313 let s = ObjectLockState {
314 mode: Some(LockMode::Compliance),
315 retain_until: Some(now() + Duration::days(7)),
316 legal_hold_on: false,
317 };
318 assert!(!s.can_delete(now(), true));
320 assert!(!s.can_delete(now(), false));
321 }
322
323 #[test]
324 fn governance_can_be_bypassed_with_header() {
325 let s = ObjectLockState {
326 mode: Some(LockMode::Governance),
327 retain_until: Some(now() + Duration::days(7)),
328 legal_hold_on: false,
329 };
330 assert!(
331 s.can_delete(now(), true),
332 "bypass=true should permit delete"
333 );
334 assert!(
335 !s.can_delete(now(), false),
336 "bypass=false should refuse delete"
337 );
338 }
339
340 #[test]
341 fn legal_hold_blocks_delete_independent_of_retention() {
342 let s = ObjectLockState {
344 mode: None,
345 retain_until: None,
346 legal_hold_on: true,
347 };
348 assert!(s.is_locked(now()));
349 assert!(!s.can_delete(now(), true), "legal hold cannot be bypassed");
350 assert!(!s.can_delete(now(), false));
351 }
352
353 #[test]
354 fn legal_hold_overrides_governance_bypass() {
355 let s = ObjectLockState {
358 mode: Some(LockMode::Governance),
359 retain_until: Some(now() + Duration::days(7)),
360 legal_hold_on: true,
361 };
362 assert!(!s.can_delete(now(), true));
363 }
364
365 #[test]
366 fn no_lock_no_block() {
367 let s = ObjectLockState::default();
368 assert!(!s.is_locked(now()));
369 assert!(s.can_delete(now(), false));
370 }
371
372 #[test]
373 fn apply_default_materialises_state_on_first_put() {
374 let m = ObjectLockManager::new();
375 m.set_bucket_default(
376 "b",
377 BucketObjectLockDefault {
378 mode: LockMode::Governance,
379 retention_days: 30,
380 },
381 );
382 let now = now();
383 m.apply_default_on_put("b", "k", now);
384 let state = m.get("b", "k").expect("state must be materialised");
385 assert_eq!(state.mode, Some(LockMode::Governance));
386 let until = state.retain_until.expect("retain_until must be set");
387 let target = now + Duration::days(30);
388 let diff = (until - target).num_seconds().abs();
390 assert!(diff <= 1, "retain_until off by {diff}s");
391 }
392
393 #[test]
394 fn apply_default_does_not_overwrite_existing_retention() {
395 let m = ObjectLockManager::new();
396 let custom_until = now() + Duration::days(365);
397 m.set(
398 "b",
399 "k",
400 ObjectLockState {
401 mode: Some(LockMode::Compliance),
402 retain_until: Some(custom_until),
403 legal_hold_on: false,
404 },
405 );
406 m.set_bucket_default(
407 "b",
408 BucketObjectLockDefault {
409 mode: LockMode::Governance,
410 retention_days: 1,
411 },
412 );
413 m.apply_default_on_put("b", "k", now());
414 let state = m.get("b", "k").unwrap();
415 assert_eq!(state.mode, Some(LockMode::Compliance));
417 assert_eq!(state.retain_until, Some(custom_until));
418 }
419
420 #[test]
421 fn apply_default_no_op_without_bucket_default() {
422 let m = ObjectLockManager::new();
423 m.apply_default_on_put("b", "k", now());
424 assert!(m.get("b", "k").is_none());
425 }
426
427 #[test]
428 fn set_legal_hold_creates_state_when_missing() {
429 let m = ObjectLockManager::new();
430 m.set_legal_hold("b", "k", true);
431 let s = m.get("b", "k").expect("state created");
432 assert!(s.legal_hold_on);
433 assert!(s.mode.is_none());
434 assert!(s.retain_until.is_none());
435 m.set_legal_hold("b", "k", false);
436 let s2 = m.get("b", "k").unwrap();
437 assert!(!s2.legal_hold_on);
438 }
439
440 #[test]
441 fn snapshot_roundtrip() {
442 let m = ObjectLockManager::new();
443 m.set(
444 "b1",
445 "k1",
446 ObjectLockState {
447 mode: Some(LockMode::Compliance),
448 retain_until: Some(Utc::now() + Duration::days(10)),
449 legal_hold_on: true,
450 },
451 );
452 m.set_bucket_default(
453 "b1",
454 BucketObjectLockDefault {
455 mode: LockMode::Governance,
456 retention_days: 7,
457 },
458 );
459 let json = m.to_json().expect("to_json");
460 let m2 = ObjectLockManager::from_json(&json).expect("from_json");
461 let s = m2.get("b1", "k1").expect("state survives roundtrip");
462 assert_eq!(s.mode, Some(LockMode::Compliance));
463 assert!(s.legal_hold_on);
464 let d = m2.bucket_default("b1").expect("default survives roundtrip");
465 assert_eq!(d.mode, LockMode::Governance);
466 assert_eq!(d.retention_days, 7);
467 }
468
469 #[test]
470 fn lock_mode_aws_string_roundtrip() {
471 assert_eq!(
472 LockMode::from_aws_str(LockMode::Governance.as_aws_str()),
473 Some(LockMode::Governance)
474 );
475 assert_eq!(
476 LockMode::from_aws_str(LockMode::Compliance.as_aws_str()),
477 Some(LockMode::Compliance)
478 );
479 assert_eq!(
480 LockMode::from_aws_str("governance"),
481 Some(LockMode::Governance)
482 );
483 assert!(LockMode::from_aws_str("nope").is_none());
484 }
485
486 #[test]
487 fn clear_removes_state() {
488 let m = ObjectLockManager::new();
489 m.set(
490 "b",
491 "k",
492 ObjectLockState {
493 mode: Some(LockMode::Governance),
494 retain_until: Some(Utc::now() + Duration::days(1)),
495 legal_hold_on: false,
496 },
497 );
498 assert!(m.get("b", "k").is_some());
499 m.clear("b", "k");
500 assert!(m.get("b", "k").is_none());
501 }
502
503 #[test]
508 fn object_lock_to_json_after_panic_recovers_via_poison() {
509 let m = ObjectLockManager::new();
510 m.set(
511 "b",
512 "k",
513 ObjectLockState {
514 mode: Some(LockMode::Compliance),
515 retain_until: Some(Utc::now() + Duration::days(7)),
516 legal_hold_on: false,
517 },
518 );
519 let m = std::sync::Arc::new(m);
520 let m_cl = std::sync::Arc::clone(&m);
521 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
522 let mut g = m_cl.states.write().expect("clean lock");
523 g.entry(("b".into(), "k2".into())).or_default();
524 panic!("force-poison");
525 }));
526 assert!(
527 m.states.is_poisoned(),
528 "write panic must poison states lock"
529 );
530 let json = m.to_json().expect("to_json after poison must succeed");
531 let m2 = ObjectLockManager::from_json(&json).expect("from_json");
532 assert!(
533 m2.get("b", "k").is_some(),
534 "recovered snapshot keeps original entry"
535 );
536 }
537}