Skip to main content

hashtree_lmdb/pool/
delete_protection.rs

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    /// Hold the exact durable delete-protection record against release while
87    /// a long-running online retirement audit uses it as an append-only Pool
88    /// authority. Ordinary writes and physical member moves remain available.
89    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
355/// An exact durable delete-protection record held against concurrent release.
356/// Dropping this value releases only the coordination lock, not the durable
357/// protection record.
358pub 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}