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