1use super::{map_heed, PoolDeleteProtectionChange, PoolDeleteProtectionStatus, PoolStore};
2use crate::managed_env::ManagedEnv;
3use hashtree_core::store::StoreError;
4use hashtree_core::{sha256, to_hex, types::Hash};
5use heed::types::Bytes;
6use heed::Database;
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11#[cfg(unix)]
12use {
13 std::fs::{File, OpenOptions},
14 std::os::fd::AsRawFd,
15 std::os::unix::fs::OpenOptionsExt,
16};
17
18pub const POOL_DELETE_PROTECTED: &str = "PoolStore logical deletion is durably protected";
19const DELETE_PROTECTION_KEY: &[u8] = b"pool-delete-protection-v1";
20const DELETE_COORDINATION_FILE: &str = ".hashtree-pool-delete-coordination-v1";
21const DELETE_PROTECTION_VERSION: u32 = 1;
22const MAX_DELETE_PROTECTION_REASON_BYTES: usize = 256;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25struct StoredDeleteProtection {
26 version: u32,
27 lease_id: Hash,
28 reason: String,
29 acquired_at_unix_secs: u64,
30}
31
32impl PoolStore {
33 pub fn delete_protection_status(
34 &self,
35 ) -> Result<Option<PoolDeleteProtectionStatus>, StoreError> {
36 delete_protection_status(&self.env, self.manifest_db)
37 }
38
39 pub fn acquire_delete_protection(
40 &self,
41 lease_id: Hash,
42 reason: &str,
43 ) -> Result<PoolDeleteProtectionChange, StoreError> {
44 require_unix_coordination()?;
45 validate_lease_id(lease_id)?;
46 validate_reason(reason)?;
47 let _coordination = self.acquire_delete_coordination_lock(true)?;
48 let mut wtxn = self.env.write_txn().map_err(map_heed)?;
49 if let Some(existing) = self
50 .manifest_db
51 .get(&wtxn, DELETE_PROTECTION_KEY)
52 .map_err(map_heed)?
53 {
54 let status = decode_status(existing)?;
55 if status.lease_id != lease_id || status.reason != reason {
56 return Err(StoreError::Other(format!(
57 "PoolStore delete protection is already held by lease {} for {:?}",
58 to_hex(&status.lease_id),
59 status.reason,
60 )));
61 }
62 return Ok(PoolDeleteProtectionChange {
63 changed: false,
64 status,
65 });
66 }
67
68 let stored = StoredDeleteProtection {
69 version: DELETE_PROTECTION_VERSION,
70 lease_id,
71 reason: reason.to_owned(),
72 acquired_at_unix_secs: unix_timestamp_now()?,
73 };
74 let encoded = encode_stored(&stored)?;
75 self.manifest_db
76 .put(&mut wtxn, DELETE_PROTECTION_KEY, &encoded)
77 .map_err(map_heed)?;
78 wtxn.commit().map_err(map_heed)?;
79 self.env.force_sync().map_err(map_heed)?;
80 Ok(PoolDeleteProtectionChange {
81 changed: true,
82 status: status_from_stored(stored, sha256(&encoded)),
83 })
84 }
85
86 pub fn hold_delete_protection(
90 &self,
91 lease_id: Hash,
92 expected_record_sha256: Hash,
93 ) -> Result<PoolDeleteProtectionGuard, StoreError> {
94 hold_delete_protection(
95 &self.catalog_path,
96 &self.env,
97 self.manifest_db,
98 lease_id,
99 expected_record_sha256,
100 )
101 }
102
103 pub fn release_delete_protection(
104 &self,
105 lease_id: Hash,
106 expected_record_sha256: Hash,
107 ) -> Result<PoolDeleteProtectionChange, StoreError> {
108 require_unix_coordination()?;
109 validate_lease_id(lease_id)?;
110 validate_lease_id(expected_record_sha256)?;
111 let _coordination = self.acquire_delete_coordination_lock(true)?;
112 let mut wtxn = self.env.write_txn().map_err(map_heed)?;
113 let existing = self
114 .manifest_db
115 .get(&wtxn, DELETE_PROTECTION_KEY)
116 .map_err(map_heed)?
117 .ok_or_else(|| StoreError::Other("PoolStore delete protection is not active".into()))?;
118 let status = decode_status(existing)?;
119 if status.lease_id != lease_id {
120 return Err(StoreError::Other(format!(
121 "PoolStore delete protection lease identity differs: expected {}, found {}",
122 to_hex(&lease_id),
123 to_hex(&status.lease_id),
124 )));
125 }
126 if status.record_sha256 != expected_record_sha256 {
127 return Err(StoreError::Other(format!(
128 "PoolStore delete protection record identity differs: expected {}, found {}",
129 to_hex(&expected_record_sha256),
130 to_hex(&status.record_sha256),
131 )));
132 }
133 self.manifest_db
134 .delete(&mut wtxn, DELETE_PROTECTION_KEY)
135 .map_err(map_heed)?;
136 wtxn.commit().map_err(map_heed)?;
137 self.env.force_sync().map_err(map_heed)?;
138 Ok(PoolDeleteProtectionChange {
139 changed: true,
140 status,
141 })
142 }
143
144 pub(super) fn require_deletes_unprotected(&self) -> Result<(), StoreError> {
145 if let Some(status) = self.delete_protection_status()? {
146 return Err(StoreError::Other(format!(
147 "{POOL_DELETE_PROTECTED}: lease {} for {:?}",
148 to_hex(&status.lease_id),
149 status.reason,
150 )));
151 }
152 Ok(())
153 }
154
155 #[cfg(unix)]
156 pub(super) fn acquire_delete_coordination_lock(
157 &self,
158 exclusive: bool,
159 ) -> Result<DeleteCoordinationGuard, StoreError> {
160 acquire_delete_coordination_lock(&self.catalog_path, exclusive)
161 }
162
163 #[cfg(not(unix))]
164 pub(super) fn acquire_delete_coordination_lock(
165 &self,
166 _exclusive: bool,
167 ) -> Result<DeleteCoordinationGuard, StoreError> {
168 Ok(DeleteCoordinationGuard)
169 }
170}
171
172pub(super) fn delete_protection_status(
173 env: &ManagedEnv,
174 manifest: Database<Bytes, Bytes>,
175) -> Result<Option<PoolDeleteProtectionStatus>, StoreError> {
176 let rtxn = env.read_txn().map_err(map_heed)?;
177 manifest
178 .get(&rtxn, DELETE_PROTECTION_KEY)
179 .map_err(map_heed)?
180 .map(decode_status)
181 .transpose()
182}
183
184pub(super) fn hold_delete_protection(
185 catalog_path: &Path,
186 env: &ManagedEnv,
187 manifest: Database<Bytes, Bytes>,
188 lease_id: Hash,
189 expected_record_sha256: Hash,
190) -> Result<PoolDeleteProtectionGuard, StoreError> {
191 require_unix_coordination()?;
192 validate_lease_id(lease_id)?;
193 validate_lease_id(expected_record_sha256)?;
194 let coordination = acquire_delete_coordination_lock(catalog_path, false)?;
195 let status = delete_protection_status(env, manifest)?
196 .ok_or_else(|| StoreError::Other("PoolStore delete protection is not active".into()))?;
197 if status.lease_id != lease_id {
198 return Err(StoreError::Other(format!(
199 "PoolStore delete protection lease identity differs: expected {}, found {}",
200 to_hex(&lease_id),
201 to_hex(&status.lease_id),
202 )));
203 }
204 if status.record_sha256 != expected_record_sha256 {
205 return Err(StoreError::Other(format!(
206 "PoolStore delete protection record identity differs: expected {}, found {}",
207 to_hex(&expected_record_sha256),
208 to_hex(&status.record_sha256),
209 )));
210 }
211 Ok(PoolDeleteProtectionGuard {
212 _coordination: coordination,
213 status,
214 })
215}
216
217#[cfg(unix)]
218fn acquire_delete_coordination_lock(
219 catalog_path: &Path,
220 exclusive: bool,
221) -> Result<DeleteCoordinationGuard, StoreError> {
222 let path = catalog_path.join(DELETE_COORDINATION_FILE);
223 let file = OpenOptions::new()
224 .read(true)
225 .write(true)
226 .create(true)
227 .mode(0o600)
228 .custom_flags(libc::O_NOFOLLOW)
229 .open(&path)
230 .map_err(|error| {
231 StoreError::Other(format!(
232 "open PoolStore delete coordination file {}: {error}",
233 path.display()
234 ))
235 })?;
236 let metadata = file.metadata().map_err(StoreError::Io)?;
237 if !metadata.is_file() {
238 return Err(StoreError::Other(format!(
239 "PoolStore delete coordination path is not a regular file: {}",
240 path.display()
241 )));
242 }
243 let operation = if exclusive {
244 libc::LOCK_EX
245 } else {
246 libc::LOCK_SH
247 };
248 loop {
249 if unsafe { libc::flock(file.as_raw_fd(), operation) } == 0 {
250 return Ok(DeleteCoordinationGuard { file });
251 }
252 let error = std::io::Error::last_os_error();
253 if error.kind() != std::io::ErrorKind::Interrupted {
254 return Err(StoreError::Other(format!(
255 "lock PoolStore delete coordination file {}: {error}",
256 path.display()
257 )));
258 }
259 }
260}
261
262#[cfg(not(unix))]
263fn acquire_delete_coordination_lock(
264 _catalog_path: &Path,
265 _exclusive: bool,
266) -> Result<DeleteCoordinationGuard, StoreError> {
267 Ok(DeleteCoordinationGuard)
268}
269
270fn validate_lease_id(lease_id: Hash) -> Result<(), StoreError> {
271 if lease_id == [0; 32] {
272 return Err(StoreError::Other(
273 "PoolStore delete protection identity must not be all-zero".into(),
274 ));
275 }
276 Ok(())
277}
278
279fn validate_reason(reason: &str) -> Result<(), StoreError> {
280 if reason.is_empty()
281 || reason.trim() != reason
282 || reason.len() > MAX_DELETE_PROTECTION_REASON_BYTES
283 || !reason
284 .bytes()
285 .all(|byte| byte.is_ascii_alphanumeric() || b" ._:/-".contains(&byte))
286 {
287 return Err(StoreError::Other(format!(
288 "PoolStore delete protection reason must be 1..={MAX_DELETE_PROTECTION_REASON_BYTES} safe ASCII bytes without surrounding whitespace"
289 )));
290 }
291 Ok(())
292}
293
294fn unix_timestamp_now() -> Result<u64, StoreError> {
295 SystemTime::now()
296 .duration_since(UNIX_EPOCH)
297 .map(|duration| duration.as_secs())
298 .map_err(|error| StoreError::Other(format!("system clock precedes Unix epoch: {error}")))
299}
300
301fn encode_stored(stored: &StoredDeleteProtection) -> Result<Vec<u8>, StoreError> {
302 rmp_serde::to_vec_named(stored)
303 .map_err(|error| StoreError::Other(format!("encode PoolStore delete protection: {error}")))
304}
305
306fn decode_status(encoded: &[u8]) -> Result<PoolDeleteProtectionStatus, StoreError> {
307 let stored: StoredDeleteProtection = rmp_serde::from_slice(encoded).map_err(|error| {
308 StoreError::Other(format!("decode PoolStore delete protection: {error}"))
309 })?;
310 if stored.version != DELETE_PROTECTION_VERSION {
311 return Err(StoreError::Other(format!(
312 "unsupported PoolStore delete protection version {}",
313 stored.version
314 )));
315 }
316 validate_lease_id(stored.lease_id)?;
317 validate_reason(&stored.reason)?;
318 if stored.acquired_at_unix_secs == 0 {
319 return Err(StoreError::Other(
320 "PoolStore delete protection acquisition time must be non-zero".into(),
321 ));
322 }
323 Ok(status_from_stored(stored, sha256(encoded)))
324}
325
326fn status_from_stored(
327 stored: StoredDeleteProtection,
328 record_sha256: Hash,
329) -> PoolDeleteProtectionStatus {
330 PoolDeleteProtectionStatus {
331 lease_id: stored.lease_id,
332 reason: stored.reason,
333 acquired_at_unix_secs: stored.acquired_at_unix_secs,
334 record_sha256,
335 }
336}
337
338#[cfg(unix)]
339pub(super) struct DeleteCoordinationGuard {
340 file: File,
341}
342
343#[cfg(unix)]
344impl Drop for DeleteCoordinationGuard {
345 fn drop(&mut self) {
346 unsafe {
347 libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
348 }
349 }
350}
351
352#[cfg(not(unix))]
353pub(super) struct DeleteCoordinationGuard;
354
355pub struct PoolDeleteProtectionGuard {
359 _coordination: DeleteCoordinationGuard,
360 status: PoolDeleteProtectionStatus,
361}
362
363impl PoolDeleteProtectionGuard {
364 pub fn status(&self) -> &PoolDeleteProtectionStatus {
365 &self.status
366 }
367}
368
369#[cfg(unix)]
370fn require_unix_coordination() -> Result<(), StoreError> {
371 Ok(())
372}
373
374#[cfg(not(unix))]
375fn require_unix_coordination() -> Result<(), StoreError> {
376 Err(StoreError::Other(
377 "durable PoolStore delete protection requires Unix flock coordination".into(),
378 ))
379}