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 self.states
163 .write()
164 .expect("object-lock state RwLock poisoned")
165 .insert((bucket.to_owned(), key.to_owned()), state);
166 }
167
168 #[must_use]
170 pub fn get(&self, bucket: &str, key: &str) -> Option<ObjectLockState> {
171 self.states
172 .read()
173 .expect("object-lock state RwLock poisoned")
174 .get(&(bucket.to_owned(), key.to_owned()))
175 .cloned()
176 }
177
178 pub fn set_legal_hold(&self, bucket: &str, key: &str, on: bool) {
182 let mut guard = self
183 .states
184 .write()
185 .expect("object-lock state RwLock poisoned");
186 let entry = guard
187 .entry((bucket.to_owned(), key.to_owned()))
188 .or_default();
189 entry.legal_hold_on = on;
190 }
191
192 pub fn set_bucket_default(&self, bucket: &str, default: BucketObjectLockDefault) {
196 self.bucket_defaults
197 .write()
198 .expect("object-lock bucket-default RwLock poisoned")
199 .insert(bucket.to_owned(), default);
200 }
201
202 #[must_use]
204 pub fn bucket_default(&self, bucket: &str) -> Option<BucketObjectLockDefault> {
205 self.bucket_defaults
206 .read()
207 .expect("object-lock bucket-default RwLock poisoned")
208 .get(bucket)
209 .copied()
210 }
211
212 pub fn apply_default_on_put(&self, bucket: &str, key: &str, now: DateTime<Utc>) {
218 let Some(default) = self.bucket_default(bucket) else {
219 return;
220 };
221 let mut guard = self
222 .states
223 .write()
224 .expect("object-lock state RwLock poisoned");
225 let key_pair = (bucket.to_owned(), key.to_owned());
226 if let Some(existing) = guard.get(&key_pair)
229 && (existing.mode.is_some() || existing.retain_until.is_some())
230 {
231 return;
232 }
233 let retain_until = now + Duration::days(i64::from(default.retention_days));
234 let entry = guard.entry(key_pair).or_default();
235 entry.mode = Some(default.mode);
236 entry.retain_until = Some(retain_until);
237 }
238
239 pub fn clear(&self, bucket: &str, key: &str) {
243 self.states
244 .write()
245 .expect("object-lock state RwLock poisoned")
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)> = self
253 .states
254 .read()
255 .expect("object-lock state RwLock poisoned")
256 .iter()
257 .map(|(k, v)| (k.clone(), v.clone()))
258 .collect();
259 let bucket_defaults = self
260 .bucket_defaults
261 .read()
262 .expect("object-lock bucket-default RwLock poisoned")
263 .clone();
264 let snap = ObjectLockSnapshot {
265 states,
266 bucket_defaults,
267 };
268 serde_json::to_string(&snap)
269 }
270
271 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
273 let snap: ObjectLockSnapshot = serde_json::from_str(s)?;
274 let mut states = HashMap::with_capacity(snap.states.len());
275 for (k, v) in snap.states {
276 states.insert(k, v);
277 }
278 Ok(Self {
279 states: RwLock::new(states),
280 bucket_defaults: RwLock::new(snap.bucket_defaults),
281 })
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn now() -> DateTime<Utc> {
290 Utc::now()
291 }
292
293 #[test]
294 fn is_locked_future_retain_until() {
295 let s = ObjectLockState {
296 mode: Some(LockMode::Governance),
297 retain_until: Some(now() + Duration::hours(1)),
298 legal_hold_on: false,
299 };
300 assert!(s.is_locked(now()));
301 }
302
303 #[test]
304 fn is_locked_past_retain_until_is_unlocked() {
305 let s = ObjectLockState {
306 mode: Some(LockMode::Governance),
307 retain_until: Some(now() - Duration::hours(1)),
308 legal_hold_on: false,
309 };
310 assert!(!s.is_locked(now()));
311 }
312
313 #[test]
314 fn compliance_cannot_be_bypassed() {
315 let s = ObjectLockState {
316 mode: Some(LockMode::Compliance),
317 retain_until: Some(now() + Duration::days(7)),
318 legal_hold_on: false,
319 };
320 assert!(!s.can_delete(now(), true));
322 assert!(!s.can_delete(now(), false));
323 }
324
325 #[test]
326 fn governance_can_be_bypassed_with_header() {
327 let s = ObjectLockState {
328 mode: Some(LockMode::Governance),
329 retain_until: Some(now() + Duration::days(7)),
330 legal_hold_on: false,
331 };
332 assert!(s.can_delete(now(), true), "bypass=true should permit delete");
333 assert!(
334 !s.can_delete(now(), false),
335 "bypass=false should refuse delete"
336 );
337 }
338
339 #[test]
340 fn legal_hold_blocks_delete_independent_of_retention() {
341 let s = ObjectLockState {
343 mode: None,
344 retain_until: None,
345 legal_hold_on: true,
346 };
347 assert!(s.is_locked(now()));
348 assert!(!s.can_delete(now(), true), "legal hold cannot be bypassed");
349 assert!(!s.can_delete(now(), false));
350 }
351
352 #[test]
353 fn legal_hold_overrides_governance_bypass() {
354 let s = ObjectLockState {
357 mode: Some(LockMode::Governance),
358 retain_until: Some(now() + Duration::days(7)),
359 legal_hold_on: true,
360 };
361 assert!(!s.can_delete(now(), true));
362 }
363
364 #[test]
365 fn no_lock_no_block() {
366 let s = ObjectLockState::default();
367 assert!(!s.is_locked(now()));
368 assert!(s.can_delete(now(), false));
369 }
370
371 #[test]
372 fn apply_default_materialises_state_on_first_put() {
373 let m = ObjectLockManager::new();
374 m.set_bucket_default(
375 "b",
376 BucketObjectLockDefault {
377 mode: LockMode::Governance,
378 retention_days: 30,
379 },
380 );
381 let now = now();
382 m.apply_default_on_put("b", "k", now);
383 let state = m.get("b", "k").expect("state must be materialised");
384 assert_eq!(state.mode, Some(LockMode::Governance));
385 let until = state.retain_until.expect("retain_until must be set");
386 let target = now + Duration::days(30);
387 let diff = (until - target).num_seconds().abs();
389 assert!(diff <= 1, "retain_until off by {diff}s");
390 }
391
392 #[test]
393 fn apply_default_does_not_overwrite_existing_retention() {
394 let m = ObjectLockManager::new();
395 let custom_until = now() + Duration::days(365);
396 m.set(
397 "b",
398 "k",
399 ObjectLockState {
400 mode: Some(LockMode::Compliance),
401 retain_until: Some(custom_until),
402 legal_hold_on: false,
403 },
404 );
405 m.set_bucket_default(
406 "b",
407 BucketObjectLockDefault {
408 mode: LockMode::Governance,
409 retention_days: 1,
410 },
411 );
412 m.apply_default_on_put("b", "k", now());
413 let state = m.get("b", "k").unwrap();
414 assert_eq!(state.mode, Some(LockMode::Compliance));
416 assert_eq!(state.retain_until, Some(custom_until));
417 }
418
419 #[test]
420 fn apply_default_no_op_without_bucket_default() {
421 let m = ObjectLockManager::new();
422 m.apply_default_on_put("b", "k", now());
423 assert!(m.get("b", "k").is_none());
424 }
425
426 #[test]
427 fn set_legal_hold_creates_state_when_missing() {
428 let m = ObjectLockManager::new();
429 m.set_legal_hold("b", "k", true);
430 let s = m.get("b", "k").expect("state created");
431 assert!(s.legal_hold_on);
432 assert!(s.mode.is_none());
433 assert!(s.retain_until.is_none());
434 m.set_legal_hold("b", "k", false);
435 let s2 = m.get("b", "k").unwrap();
436 assert!(!s2.legal_hold_on);
437 }
438
439 #[test]
440 fn snapshot_roundtrip() {
441 let m = ObjectLockManager::new();
442 m.set(
443 "b1",
444 "k1",
445 ObjectLockState {
446 mode: Some(LockMode::Compliance),
447 retain_until: Some(Utc::now() + Duration::days(10)),
448 legal_hold_on: true,
449 },
450 );
451 m.set_bucket_default(
452 "b1",
453 BucketObjectLockDefault {
454 mode: LockMode::Governance,
455 retention_days: 7,
456 },
457 );
458 let json = m.to_json().expect("to_json");
459 let m2 = ObjectLockManager::from_json(&json).expect("from_json");
460 let s = m2.get("b1", "k1").expect("state survives roundtrip");
461 assert_eq!(s.mode, Some(LockMode::Compliance));
462 assert!(s.legal_hold_on);
463 let d = m2.bucket_default("b1").expect("default survives roundtrip");
464 assert_eq!(d.mode, LockMode::Governance);
465 assert_eq!(d.retention_days, 7);
466 }
467
468 #[test]
469 fn lock_mode_aws_string_roundtrip() {
470 assert_eq!(
471 LockMode::from_aws_str(LockMode::Governance.as_aws_str()),
472 Some(LockMode::Governance)
473 );
474 assert_eq!(
475 LockMode::from_aws_str(LockMode::Compliance.as_aws_str()),
476 Some(LockMode::Compliance)
477 );
478 assert_eq!(LockMode::from_aws_str("governance"), Some(LockMode::Governance));
479 assert!(LockMode::from_aws_str("nope").is_none());
480 }
481
482 #[test]
483 fn clear_removes_state() {
484 let m = ObjectLockManager::new();
485 m.set(
486 "b",
487 "k",
488 ObjectLockState {
489 mode: Some(LockMode::Governance),
490 retain_until: Some(Utc::now() + Duration::days(1)),
491 legal_hold_on: false,
492 },
493 );
494 assert!(m.get("b", "k").is_some());
495 m.clear("b", "k");
496 assert!(m.get("b", "k").is_none());
497 }
498}