Skip to main content

vsh_store/
lib.rs

1//! Immutable blob storage plus transaction records and validated state changes.
2//!
3//! [`FileTransactionStore`] provides checksummed append-log durability, bounded
4//! two-slot compaction, and standard-library cross-process file locks without adding
5//! a database dependency. The process-local [`MemoryTransactionStore`] remains
6//! available for focused tests.
7
8use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt;
11use std::fs::File;
12use std::io::{self, Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex, MutexGuard};
16
17use cap_std::fs::{Dir, OpenOptions};
18
19use vsh_types::{
20    ApprovalBinding, ApprovalId, BlobId, PrincipalId, SnapshotId, TransactionId, TransactionState,
21    TransitionError,
22};
23
24mod directory;
25mod persistent;
26
27pub use directory::{DataDirectory, DataDirectoryError};
28pub use persistent::{FileStoreConfig, FileTransactionStore};
29
30static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
31
32/// Filesystem-backed immutable content-addressed blob storage.
33///
34/// Blobs are written to a temporary file in their final shard, synchronized, and then
35/// atomically renamed. Every read re-hashes the bytes before returning them.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct BlobStore {
38    blobs_dir: PathBuf,
39    directory: DataDirectory,
40}
41
42impl BlobStore {
43    /// Open or create a blob store below `data_dir`.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`BlobStoreError::Io`] if the store directory cannot be created.
48    pub fn open(data_dir: impl AsRef<Path>) -> Result<Self, BlobStoreError> {
49        let data_dir = data_dir.as_ref();
50        let directory = DataDirectory::open_trusted(data_dir).map_err(|source| {
51            BlobStoreError::io("open data directory", data_dir, io::Error::other(source))
52        })?;
53        Self::open_in(&directory)
54    }
55
56    /// Open or create a blob store below a pinned data-directory capability.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`BlobStoreError::Io`] if the real `blobs` directory cannot be
61    /// created, pinned, or synchronized.
62    pub fn open_in(data_directory: &DataDirectory) -> Result<Self, BlobStoreError> {
63        let directory = data_directory.open_real_child("blobs").map_err(|source| {
64            BlobStoreError::io(
65                "open capability-rooted blob store",
66                &data_directory.path().join("blobs"),
67                source,
68            )
69        })?;
70        directory::sync_directory(data_directory.directory()).map_err(|source| {
71            BlobStoreError::io("sync blob-store parent", data_directory.path(), source)
72        })?;
73        Ok(Self {
74            blobs_dir: directory.path().to_path_buf(),
75            directory,
76        })
77    }
78
79    /// Return the directory containing the content-addressed shards.
80    #[must_use]
81    pub fn blobs_dir(&self) -> &Path {
82        &self.blobs_dir
83    }
84
85    /// Store bytes exactly once and return their BLAKE3 identity.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error for I/O failures or if an existing blob fails hash verification.
90    pub fn put(&self, bytes: &[u8]) -> Result<BlobId, BlobStoreError> {
91        let id = BlobId::digest(bytes);
92        let (hex, shard_path, target_path) = self.location_for(id);
93        let shard_name = &hex[..2];
94        let target_name = &hex[2..];
95        let shard = self
96            .directory
97            .open_real_child(shard_name)
98            .map_err(|source| BlobStoreError::io("open blob shard", &shard_path, source))?;
99        if entry_exists(shard.directory(), target_name)
100            .map_err(|source| BlobStoreError::io("inspect immutable blob", &target_path, source))?
101        {
102            Self::verify_existing(id, shard.directory(), target_name, &target_path)?;
103            return Ok(id);
104        }
105
106        let (mut file, temporary_name, temporary_path) =
107            Self::create_temporary(shard.directory(), &shard_path)?;
108        if let Err(source) = file.write_all(bytes) {
109            drop(file);
110            let _ = shard.directory().remove_file(&temporary_name);
111            return Err(BlobStoreError::io(
112                "write temporary blob",
113                &temporary_path,
114                source,
115            ));
116        }
117        if let Err(source) = file.sync_all() {
118            drop(file);
119            let _ = shard.directory().remove_file(&temporary_name);
120            return Err(BlobStoreError::io(
121                "sync temporary blob",
122                &temporary_path,
123                source,
124            ));
125        }
126        drop(file);
127
128        match shard
129            .directory()
130            .rename(&temporary_name, shard.directory(), target_name)
131        {
132            Ok(()) => {}
133            Err(_source)
134                if entry_exists(shard.directory(), target_name).map_err(|source| {
135                    BlobStoreError::io("inspect raced immutable blob", &target_path, source)
136                })? =>
137            {
138                let _ = shard.directory().remove_file(&temporary_name);
139                Self::verify_existing(id, shard.directory(), target_name, &target_path)?;
140                return Ok(id);
141            }
142            Err(source) => {
143                let _ = shard.directory().remove_file(&temporary_name);
144                return Err(BlobStoreError::io(
145                    "install immutable blob",
146                    &target_path,
147                    source,
148                ));
149            }
150        }
151
152        directory::sync_directory(shard.directory())
153            .map_err(|source| BlobStoreError::io("sync blob shard", &shard_path, source))?;
154        Self::verify_existing(id, shard.directory(), target_name, &target_path)?;
155        Ok(id)
156    }
157
158    /// Load and hash-verify one immutable blob.
159    ///
160    /// # Errors
161    ///
162    /// Returns an I/O error when the blob is unavailable and [`BlobStoreError::Corrupt`]
163    /// when its bytes no longer match the requested identity.
164    pub fn get(&self, id: BlobId) -> Result<Vec<u8>, BlobStoreError> {
165        self.get_bounded(id, usize::MAX)
166    }
167
168    /// Load and hash-verify one immutable blob without allocating beyond `maximum`.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`BlobStoreError::SizeLimit`] before reading when metadata already
173    /// exceeds the bound, or if a concurrently enlarged file crosses it while read.
174    pub fn get_bounded(&self, id: BlobId, maximum: usize) -> Result<Vec<u8>, BlobStoreError> {
175        let (hex, _shard_path, path) = self.location_for(id);
176        let shard_name = &hex[..2];
177        let target_name = &hex[2..];
178        let shard = self
179            .directory
180            .directory()
181            .open_dir(shard_name)
182            .map_err(|source| BlobStoreError::io("open blob shard", &path, source))?;
183        let mut options = OpenOptions::new();
184        options.read(true);
185        let mut file = directory::open_real_file(&shard, target_name, &options)
186            .map_err(|source| BlobStoreError::io("open immutable blob", &path, source))?;
187        let declared = usize::try_from(
188            file.metadata()
189                .map_err(|source| BlobStoreError::io("inspect immutable blob", &path, source))?
190                .len(),
191        )
192        .unwrap_or(usize::MAX);
193        if declared > maximum {
194            return Err(BlobStoreError::SizeLimit {
195                path,
196                observed: declared,
197                maximum,
198            });
199        }
200        let mut bytes = Vec::with_capacity(declared);
201        Read::by_ref(&mut file)
202            .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
203            .read_to_end(&mut bytes)
204            .map_err(|source| BlobStoreError::io("read immutable blob", &path, source))?;
205        if bytes.len() > maximum {
206            return Err(BlobStoreError::SizeLimit {
207                path,
208                observed: bytes.len(),
209                maximum,
210            });
211        }
212        let actual = BlobId::digest(&bytes);
213        if actual != id {
214            return Err(BlobStoreError::Corrupt {
215                path,
216                expected: id,
217                actual,
218            });
219        }
220        Ok(bytes)
221    }
222
223    /// Return whether a verified blob exists.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when a present blob cannot be read or fails verification.
228    pub fn contains(&self, id: BlobId) -> Result<bool, BlobStoreError> {
229        let (hex, _shard_path, path) = self.location_for(id);
230        let shard_name = &hex[..2];
231        let target_name = &hex[2..];
232        let shard = match self.directory.directory().open_dir(shard_name) {
233            Ok(shard) => shard,
234            Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(false),
235            Err(source) => return Err(BlobStoreError::io("open blob shard", &path, source)),
236        };
237        if !entry_exists(&shard, target_name)
238            .map_err(|source| BlobStoreError::io("inspect immutable blob", &path, source))?
239        {
240            return Ok(false);
241        }
242        Self::verify_existing(id, &shard, target_name, &path)?;
243        Ok(true)
244    }
245
246    fn create_temporary(
247        shard: &Dir,
248        shard_path: &Path,
249    ) -> Result<(File, String, PathBuf), BlobStoreError> {
250        for _ in 0..1024 {
251            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
252            let name = format!(".vsh-blob-{}-{sequence}.tmp", std::process::id());
253            let path = shard_path.join(&name);
254            let mut options = OpenOptions::new();
255            options.write(true).create_new(true);
256            match directory::open_real_file(shard, &name, &options) {
257                Ok(file) => return Ok((file, name, path)),
258                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
259                Err(source) => {
260                    return Err(BlobStoreError::io("create temporary blob", &path, source));
261                }
262            }
263        }
264        Err(BlobStoreError::TemporaryNameExhausted {
265            directory: shard_path.to_owned(),
266        })
267    }
268
269    fn verify_existing(
270        id: BlobId,
271        shard: &Dir,
272        name: &str,
273        path: &Path,
274    ) -> Result<(), BlobStoreError> {
275        let mut options = OpenOptions::new();
276        options.read(true);
277        let mut file = directory::open_real_file(shard, name, &options)
278            .map_err(|source| BlobStoreError::io("verify immutable blob", path, source))?;
279        let actual = hash_file(&mut file)
280            .map_err(|source| BlobStoreError::io("verify immutable blob", path, source))?;
281        if actual != id {
282            return Err(BlobStoreError::Corrupt {
283                path: path.to_owned(),
284                expected: id,
285                actual,
286            });
287        }
288        Ok(())
289    }
290
291    #[cfg(test)]
292    fn path_for(&self, id: BlobId) -> PathBuf {
293        self.location_for(id).2
294    }
295
296    fn location_for(&self, id: BlobId) -> (String, PathBuf, PathBuf) {
297        let hex = id.to_string();
298        let shard = self.blobs_dir.join(&hex[..2]);
299        let target = shard.join(&hex[2..]);
300        (hex, shard, target)
301    }
302}
303
304fn entry_exists(directory: &Dir, name: &str) -> io::Result<bool> {
305    match directory.symlink_metadata(name) {
306        Ok(metadata) if metadata.is_file() && !metadata.is_symlink() => Ok(true),
307        Ok(_) => Err(io::Error::new(
308            io::ErrorKind::InvalidData,
309            "immutable blob path is not a real file",
310        )),
311        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
312        Err(source) => Err(source),
313    }
314}
315
316fn hash_file(file: &mut File) -> io::Result<BlobId> {
317    let mut hasher = blake3::Hasher::new();
318    let mut buffer = [0_u8; 16 * 1024];
319    loop {
320        let read = file.read(&mut buffer)?;
321        if read == 0 {
322            break;
323        }
324        hasher.update(&buffer[..read]);
325    }
326    Ok(BlobId::from_bytes(*hasher.finalize().as_bytes()))
327}
328
329/// A blob-store operation failed or immutable content did not verify.
330#[derive(Debug)]
331#[non_exhaustive]
332pub enum BlobStoreError {
333    /// A filesystem operation failed.
334    Io {
335        /// Short stable operation description.
336        operation: &'static str,
337        /// Exact path involved in the failure.
338        path: PathBuf,
339        /// Underlying host error.
340        source: io::Error,
341    },
342    /// Stored bytes no longer match their content address.
343    Corrupt {
344        /// Corrupt blob path.
345        path: PathBuf,
346        /// Identity requested by the caller.
347        expected: BlobId,
348        /// Identity computed from the stored bytes.
349        actual: BlobId,
350    },
351    /// A blob exceeded a caller-provided allocation bound.
352    SizeLimit {
353        /// Oversized blob path.
354        path: PathBuf,
355        /// Observed bytes.
356        observed: usize,
357        /// Maximum accepted bytes.
358        maximum: usize,
359    },
360    /// Repeated unique temporary names collided unexpectedly.
361    TemporaryNameExhausted {
362        /// Shard in which allocation failed.
363        directory: PathBuf,
364    },
365}
366
367impl BlobStoreError {
368    fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
369        Self::Io {
370            operation,
371            path: path.to_owned(),
372            source,
373        }
374    }
375}
376
377impl fmt::Display for BlobStoreError {
378    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
379        match self {
380            Self::Io {
381                operation,
382                path,
383                source,
384            } => write!(formatter, "{operation} at {}: {source}", path.display()),
385            Self::Corrupt {
386                path,
387                expected,
388                actual,
389            } => write!(
390                formatter,
391                "blob corruption at {}: expected {expected}, got {actual}",
392                path.display()
393            ),
394            Self::SizeLimit {
395                path,
396                observed,
397                maximum,
398            } => write!(
399                formatter,
400                "blob at {} is {observed} bytes; maximum is {maximum}",
401                path.display()
402            ),
403            Self::TemporaryNameExhausted { directory } => write!(
404                formatter,
405                "could not allocate a unique temporary blob in {}",
406                directory.display()
407            ),
408        }
409    }
410}
411
412impl Error for BlobStoreError {
413    fn source(&self) -> Option<&(dyn Error + 'static)> {
414        match self {
415            Self::Io { source, .. } => Some(source),
416            Self::Corrupt { .. } | Self::SizeLimit { .. } | Self::TemporaryNameExhausted { .. } => {
417                None
418            }
419        }
420    }
421}
422
423/// The storage-facing identity and state of a transaction.
424#[derive(Clone, Debug, Eq, PartialEq)]
425pub struct TransactionRecord {
426    id: TransactionId,
427    base_snapshot: SnapshotId,
428    state: TransactionState,
429    artifact: Option<BlobId>,
430    approval: Option<ApprovalGrant>,
431}
432
433impl TransactionRecord {
434    /// Create a transaction record in the only valid initial state.
435    #[must_use]
436    pub const fn new(id: TransactionId, base_snapshot: SnapshotId) -> Self {
437        Self {
438            id,
439            base_snapshot,
440            state: TransactionState::Created,
441            artifact: None,
442            approval: None,
443        }
444    }
445
446    /// Bind an immutable content-addressed transaction artifact before persistence.
447    #[must_use]
448    pub const fn with_artifact(mut self, artifact: BlobId) -> Self {
449        self.artifact = Some(artifact);
450        self
451    }
452
453    /// Return the transaction identity.
454    #[must_use]
455    pub const fn id(&self) -> TransactionId {
456        self.id
457    }
458
459    /// Return the immutable base snapshot identity.
460    #[must_use]
461    pub const fn base_snapshot(&self) -> SnapshotId {
462        self.base_snapshot
463    }
464
465    /// Return the current persisted state.
466    #[must_use]
467    pub const fn state(&self) -> TransactionState {
468        self.state
469    }
470
471    /// Return the immutable transaction artifact identity, when one is retained.
472    #[must_use]
473    pub const fn artifact(&self) -> Option<BlobId> {
474        self.artifact
475    }
476
477    /// Return the exact independent approval grant, when one exists.
478    #[must_use]
479    pub const fn approval(&self) -> Option<ApprovalGrant> {
480        self.approval
481    }
482
483    /// Apply a valid state transition or leave the record unchanged.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`TransitionError`] when `next` is not reachable from the current state.
488    pub fn transition(&mut self, next: TransactionState) -> Result<(), TransitionError> {
489        if !self.state.can_transition_to(next) {
490            return Err(TransitionError {
491                from: self.state,
492                to: next,
493            });
494        }
495        self.state = next;
496        Ok(())
497    }
498}
499
500/// An independent approval bound to one exact transaction and expiry window.
501#[derive(Clone, Copy, Debug, Eq, PartialEq)]
502pub struct ApprovalGrant {
503    binding: ApprovalBinding,
504    id: ApprovalId,
505}
506
507impl ApprovalGrant {
508    /// Construct a bounded approval grant.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`ApprovalGrantError`] unless expiry is strictly after issuance.
513    pub fn new(
514        transaction: TransactionId,
515        principal: PrincipalId,
516        issued_at_unix_ms: u64,
517        expires_at_unix_ms: u64,
518    ) -> Result<Self, ApprovalGrantError> {
519        if expires_at_unix_ms <= issued_at_unix_ms {
520            return Err(ApprovalGrantError::InvalidWindow {
521                issued_at_unix_ms,
522                expires_at_unix_ms,
523            });
524        }
525        let binding = ApprovalBinding {
526            transaction,
527            principal,
528            issued_at_unix_ms,
529            expires_at_unix_ms,
530        };
531        Ok(Self {
532            binding,
533            id: binding.approval_id(),
534        })
535    }
536
537    /// Return the immutable approval identity.
538    #[must_use]
539    pub const fn id(self) -> ApprovalId {
540        self.id
541    }
542
543    /// Return the exact transaction approved.
544    #[must_use]
545    pub const fn transaction(self) -> TransactionId {
546        self.binding.transaction
547    }
548
549    /// Return the opaque approving principal.
550    #[must_use]
551    pub const fn principal(self) -> PrincipalId {
552        self.binding.principal
553    }
554
555    /// Return issuance time in Unix milliseconds.
556    #[must_use]
557    pub const fn issued_at_unix_ms(self) -> u64 {
558        self.binding.issued_at_unix_ms
559    }
560
561    /// Return exclusive expiry time in Unix milliseconds.
562    #[must_use]
563    pub const fn expires_at_unix_ms(self) -> u64 {
564        self.binding.expires_at_unix_ms
565    }
566
567    /// Return whether the grant is expired at `now_unix_ms`.
568    #[must_use]
569    pub const fn is_expired_at(self, now_unix_ms: u64) -> bool {
570        now_unix_ms >= self.binding.expires_at_unix_ms
571    }
572}
573
574/// Invalid approval grant input.
575#[derive(Clone, Copy, Debug, Eq, PartialEq)]
576pub enum ApprovalGrantError {
577    /// Expiry did not follow issuance.
578    InvalidWindow {
579        /// Supplied issuance time.
580        issued_at_unix_ms: u64,
581        /// Supplied expiry time.
582        expires_at_unix_ms: u64,
583    },
584}
585
586impl fmt::Display for ApprovalGrantError {
587    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
588        match self {
589            Self::InvalidWindow {
590                issued_at_unix_ms,
591                expires_at_unix_ms,
592            } => write!(
593                formatter,
594                "approval expiry {expires_at_unix_ms} must follow issuance {issued_at_unix_ms}"
595            ),
596        }
597    }
598}
599
600impl Error for ApprovalGrantError {}
601
602/// Non-cloneable proof that one transaction won the atomic commit reservation.
603#[derive(Debug, Eq, PartialEq)]
604pub struct CommitReservation {
605    transaction: TransactionId,
606    base_snapshot: SnapshotId,
607}
608
609impl CommitReservation {
610    /// Return the reserved transaction identity.
611    #[must_use]
612    pub const fn transaction(&self) -> TransactionId {
613        self.transaction
614    }
615
616    /// Return the immutable snapshot the transaction was simulated against.
617    #[must_use]
618    pub const fn base_snapshot(&self) -> SnapshotId {
619        self.base_snapshot
620    }
621}
622
623/// Atomic transaction-state operations required by the runtime and committer.
624pub trait TransactionStore: Send + Sync {
625    /// Insert one record whose state was reached through validated in-memory edges.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`TransactionStoreError::Duplicate`] when the ID already exists.
630    fn create(&self, record: TransactionRecord) -> Result<(), TransactionStoreError>;
631
632    /// Load one immutable record snapshot.
633    ///
634    /// # Errors
635    ///
636    /// Returns [`TransactionStoreError::NotFound`] for an unknown ID.
637    fn get(&self, id: TransactionId) -> Result<TransactionRecord, TransactionStoreError>;
638
639    /// Compare exact state and perform one valid transition atomically.
640    ///
641    /// # Errors
642    ///
643    /// Returns a state conflict or transition error without modifying the record.
644    fn compare_and_transition(
645        &self,
646        id: TransactionId,
647        expected: TransactionState,
648        next: TransactionState,
649    ) -> Result<TransactionRecord, TransactionStoreError>;
650
651    /// Bind an independent grant and move a pending state to `Approved` atomically.
652    ///
653    /// # Errors
654    ///
655    /// Returns an error for an ID mismatch, missing record, or wrong current state.
656    fn approve(
657        &self,
658        id: TransactionId,
659        grant: ApprovalGrant,
660    ) -> Result<TransactionRecord, TransactionStoreError>;
661
662    /// Atomically consume `AutoApproved` or unexpired `Approved` into `Reserved`.
663    ///
664    /// # Errors
665    ///
666    /// Returns a state conflict, missing-approval error, or expiry error. An expired
667    /// record is atomically moved to `Expired`.
668    fn reserve(
669        &self,
670        id: TransactionId,
671        now_unix_ms: u64,
672    ) -> Result<CommitReservation, TransactionStoreError>;
673}
674
675/// Process-local reference backend for state-machine and concurrency correctness.
676///
677/// The lock covers only short record operations; virtual execution never occurs while
678/// it is held. Production runtimes use [`FileTransactionStore`] for crash durability.
679#[derive(Clone, Debug, Default)]
680pub struct MemoryTransactionStore {
681    records: Arc<Mutex<BTreeMap<TransactionId, TransactionRecord>>>,
682}
683
684impl MemoryTransactionStore {
685    fn lock(
686        &self,
687    ) -> Result<MutexGuard<'_, BTreeMap<TransactionId, TransactionRecord>>, TransactionStoreError>
688    {
689        self.records
690            .lock()
691            .map_err(|_| TransactionStoreError::Poisoned)
692    }
693}
694
695impl TransactionStore for MemoryTransactionStore {
696    fn create(&self, record: TransactionRecord) -> Result<(), TransactionStoreError> {
697        let mut records = self.lock()?;
698        if records.contains_key(&record.id()) {
699            return Err(TransactionStoreError::Duplicate { id: record.id() });
700        }
701        records.insert(record.id(), record);
702        Ok(())
703    }
704
705    fn get(&self, id: TransactionId) -> Result<TransactionRecord, TransactionStoreError> {
706        self.lock()?
707            .get(&id)
708            .cloned()
709            .ok_or(TransactionStoreError::NotFound { id })
710    }
711
712    fn compare_and_transition(
713        &self,
714        id: TransactionId,
715        expected: TransactionState,
716        next: TransactionState,
717    ) -> Result<TransactionRecord, TransactionStoreError> {
718        let mut records = self.lock()?;
719        let record = records
720            .get_mut(&id)
721            .ok_or(TransactionStoreError::NotFound { id })?;
722        if record.state() != expected {
723            return Err(TransactionStoreError::StateConflict {
724                id,
725                expected,
726                actual: record.state(),
727            });
728        }
729        record
730            .transition(next)
731            .map_err(TransactionStoreError::Transition)?;
732        Ok(record.clone())
733    }
734
735    fn approve(
736        &self,
737        id: TransactionId,
738        grant: ApprovalGrant,
739    ) -> Result<TransactionRecord, TransactionStoreError> {
740        if grant.transaction() != id {
741            return Err(TransactionStoreError::ApprovalBindingMismatch {
742                requested: id,
743                bound: grant.transaction(),
744            });
745        }
746        let mut records = self.lock()?;
747        let record = records
748            .get_mut(&id)
749            .ok_or(TransactionStoreError::NotFound { id })?;
750        if record.state() != TransactionState::PendingApproval {
751            return Err(TransactionStoreError::StateConflict {
752                id,
753                expected: TransactionState::PendingApproval,
754                actual: record.state(),
755            });
756        }
757        record
758            .transition(TransactionState::Approved)
759            .map_err(TransactionStoreError::Transition)?;
760        record.approval = Some(grant);
761        Ok(record.clone())
762    }
763
764    fn reserve(
765        &self,
766        id: TransactionId,
767        now_unix_ms: u64,
768    ) -> Result<CommitReservation, TransactionStoreError> {
769        let mut records = self.lock()?;
770        let record = records
771            .get_mut(&id)
772            .ok_or(TransactionStoreError::NotFound { id })?;
773        match record.state() {
774            TransactionState::AutoApproved => {}
775            TransactionState::Approved => {
776                let grant = record
777                    .approval()
778                    .ok_or(TransactionStoreError::MissingApproval { id })?;
779                if grant.is_expired_at(now_unix_ms) {
780                    record
781                        .transition(TransactionState::Expired)
782                        .map_err(TransactionStoreError::Transition)?;
783                    return Err(TransactionStoreError::ApprovalExpired {
784                        id,
785                        expired_at_unix_ms: grant.expires_at_unix_ms(),
786                        observed_at_unix_ms: now_unix_ms,
787                    });
788                }
789            }
790            actual => {
791                return Err(TransactionStoreError::NotReservable { id, actual });
792            }
793        }
794        record
795            .transition(TransactionState::Reserved)
796            .map_err(TransactionStoreError::Transition)?;
797        Ok(CommitReservation {
798            transaction: id,
799            base_snapshot: record.base_snapshot(),
800        })
801    }
802}
803
804/// Atomic transaction-store failure.
805#[derive(Clone, Copy, Debug, Eq, PartialEq)]
806#[non_exhaustive]
807pub enum TransactionStoreError {
808    /// The transaction ID already exists.
809    Duplicate {
810        /// Duplicate ID.
811        id: TransactionId,
812    },
813    /// No record has this ID.
814    NotFound {
815        /// Missing ID.
816        id: TransactionId,
817    },
818    /// Compare-and-transition observed another state.
819    StateConflict {
820        /// Transaction ID.
821        id: TransactionId,
822        /// Required current state.
823        expected: TransactionState,
824        /// State actually observed.
825        actual: TransactionState,
826    },
827    /// The requested state edge is invalid.
828    Transition(TransitionError),
829    /// A grant for another exact artifact was presented.
830    ApprovalBindingMismatch {
831        /// Transaction the caller attempted to approve.
832        requested: TransactionId,
833        /// Transaction actually covered by the grant.
834        bound: TransactionId,
835    },
836    /// An approved state lacked its grant and therefore failed closed.
837    MissingApproval {
838        /// Affected transaction.
839        id: TransactionId,
840    },
841    /// Approval expired and the record was moved to `Expired`.
842    ApprovalExpired {
843        /// Affected transaction.
844        id: TransactionId,
845        /// Exclusive grant expiry.
846        expired_at_unix_ms: u64,
847        /// Time supplied to reservation.
848        observed_at_unix_ms: u64,
849    },
850    /// Current state can never win a new reservation.
851    NotReservable {
852        /// Affected transaction.
853        id: TransactionId,
854        /// State actually observed.
855        actual: TransactionState,
856    },
857    /// Durable state-file or cross-process lock I/O failed.
858    PersistentIo {
859        /// Stable operation label.
860        operation: &'static str,
861        /// Portable operating-system error category.
862        kind: io::ErrorKind,
863    },
864    /// Durable state bytes failed structural, checksum, or lifecycle validation.
865    PersistentCorrupt {
866        /// Byte offset at which validation failed.
867        offset: u64,
868        /// Stable corruption reason.
869        reason: &'static str,
870    },
871    /// The append-only durable log exceeded its configured byte bound.
872    PersistentLogLimit {
873        /// Bytes the next durable state would require.
874        observed: u64,
875        /// Configured byte ceiling.
876        maximum: u64,
877    },
878    /// Unique durable transaction count exceeded its configured bound.
879    PersistentRecordLimit {
880        /// Unique records the operation would retain.
881        observed: usize,
882        /// Configured record ceiling.
883        maximum: usize,
884    },
885    /// Another thread panicked while mutating the in-memory reference backend.
886    Poisoned,
887}
888
889impl fmt::Display for TransactionStoreError {
890    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
891        match self {
892            Self::Duplicate { id } => write!(formatter, "duplicate transaction: {id}"),
893            Self::NotFound { id } => write!(formatter, "unknown transaction: {id}"),
894            Self::StateConflict {
895                id,
896                expected,
897                actual,
898            } => write!(
899                formatter,
900                "transaction {id} state conflict: expected {expected:?}, got {actual:?}"
901            ),
902            Self::Transition(source) => fmt::Display::fmt(source, formatter),
903            Self::ApprovalBindingMismatch { requested, bound } => write!(
904                formatter,
905                "approval binding mismatch: requested {requested}, grant covers {bound}"
906            ),
907            Self::MissingApproval { id } => {
908                write!(formatter, "transaction {id} has no bound approval grant")
909            }
910            Self::ApprovalExpired {
911                id,
912                expired_at_unix_ms,
913                observed_at_unix_ms,
914            } => write!(
915                formatter,
916                "transaction {id} approval expired at {expired_at_unix_ms}; observed {observed_at_unix_ms}"
917            ),
918            Self::NotReservable { id, actual } => {
919                write!(
920                    formatter,
921                    "transaction {id} is not reservable from {actual:?}"
922                )
923            }
924            Self::PersistentIo { operation, kind } => {
925                write!(
926                    formatter,
927                    "persistent transaction store {operation} failed: {kind}"
928                )
929            }
930            Self::PersistentCorrupt { offset, reason } => write!(
931                formatter,
932                "persistent transaction store is corrupt at byte {offset}: {reason}"
933            ),
934            Self::PersistentLogLimit { observed, maximum } => write!(
935                formatter,
936                "persistent transaction log would use {observed} bytes; maximum is {maximum}"
937            ),
938            Self::PersistentRecordLimit { observed, maximum } => write!(
939                formatter,
940                "persistent transaction store would retain {observed} records; maximum is {maximum}"
941            ),
942            Self::Poisoned => formatter.write_str("transaction store lock is poisoned"),
943        }
944    }
945}
946
947impl Error for TransactionStoreError {
948    fn source(&self) -> Option<&(dyn Error + 'static)> {
949        match self {
950            Self::Transition(source) => Some(source),
951            Self::Duplicate { .. }
952            | Self::NotFound { .. }
953            | Self::StateConflict { .. }
954            | Self::ApprovalBindingMismatch { .. }
955            | Self::MissingApproval { .. }
956            | Self::ApprovalExpired { .. }
957            | Self::NotReservable { .. }
958            | Self::PersistentIo { .. }
959            | Self::PersistentCorrupt { .. }
960            | Self::PersistentLogLimit { .. }
961            | Self::PersistentRecordLimit { .. }
962            | Self::Poisoned => None,
963        }
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use std::fs;
971    use std::io;
972    use std::sync::{Arc, Barrier};
973    use std::thread;
974
975    struct TestDirectory(PathBuf);
976
977    impl Drop for TestDirectory {
978        fn drop(&mut self) {
979            let _ = fs::remove_dir_all(&self.0);
980        }
981    }
982
983    fn test_store(name: &str) -> (TestDirectory, BlobStore) {
984        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
985        let root = std::env::temp_dir().join(format!(
986            "vsh-store-test-{}-{sequence}-{name}",
987            std::process::id()
988        ));
989        let guard = TestDirectory(root.clone());
990        let store = BlobStore::open(root).unwrap();
991        (guard, store)
992    }
993
994    #[test]
995    fn blobs_are_content_addressed_deduplicated_and_verified() {
996        let (_guard, store) = test_store("round-trip");
997        let bytes = b"immutable vsh blob";
998
999        let first = store.put(bytes).unwrap();
1000        let second = store.put(bytes).unwrap();
1001
1002        assert_eq!(first, second);
1003        assert_eq!(store.get(first).unwrap(), bytes);
1004        assert!(store.contains(first).unwrap());
1005        assert!(!store.contains(BlobId::from_bytes([0xff; 32])).unwrap());
1006    }
1007
1008    #[test]
1009    fn corrupt_blob_is_never_returned() {
1010        let (_guard, store) = test_store("corrupt");
1011        let id = store.put(b"expected").unwrap();
1012        let path = store.path_for(id);
1013        fs::write(&path, b"tampered").unwrap();
1014
1015        let error = store.get(id).unwrap_err();
1016        assert!(matches!(
1017            error,
1018            BlobStoreError::Corrupt {
1019                expected,
1020                actual: _,
1021                path: _
1022            } if expected == id
1023        ));
1024        assert!(store.put(b"expected").is_err());
1025    }
1026
1027    #[test]
1028    fn bounded_blob_read_rejects_oversize_before_returning_bytes() {
1029        let (_guard, store) = test_store("bounded");
1030        let id = store.put(b"12345678").unwrap();
1031
1032        let error = store.get_bounded(id, 7).unwrap_err();
1033        assert!(matches!(
1034            error,
1035            BlobStoreError::SizeLimit {
1036                observed: 8,
1037                maximum: 7,
1038                path: _
1039            }
1040        ));
1041        assert_eq!(store.get_bounded(id, 8).unwrap(), b"12345678");
1042    }
1043
1044    #[test]
1045    fn record_preserves_identity_and_validates_transitions() {
1046        let id = TransactionId::from_bytes([1; 32]);
1047        let snapshot = SnapshotId::from_bytes([2; 32]);
1048        let mut record = TransactionRecord::new(id, snapshot);
1049
1050        assert_eq!(record.id(), id);
1051        assert_eq!(record.base_snapshot(), snapshot);
1052        assert_eq!(record.state(), TransactionState::Created);
1053
1054        record.transition(TransactionState::Running).unwrap();
1055        assert_eq!(record.state(), TransactionState::Running);
1056
1057        let error = record.transition(TransactionState::Committed).unwrap_err();
1058        assert_eq!(
1059            error,
1060            TransitionError {
1061                from: TransactionState::Running,
1062                to: TransactionState::Committed,
1063            }
1064        );
1065        assert_eq!(record.state(), TransactionState::Running);
1066    }
1067
1068    fn policy_complete_store(id: TransactionId, state: TransactionState) -> MemoryTransactionStore {
1069        let store = MemoryTransactionStore::default();
1070        store
1071            .create(TransactionRecord::new(id, SnapshotId::from_bytes([2; 32])))
1072            .unwrap();
1073        store
1074            .compare_and_transition(id, TransactionState::Created, TransactionState::Running)
1075            .unwrap();
1076        store
1077            .compare_and_transition(
1078                id,
1079                TransactionState::Running,
1080                TransactionState::VirtualComplete,
1081            )
1082            .unwrap();
1083        store
1084            .compare_and_transition(id, TransactionState::VirtualComplete, state)
1085            .unwrap();
1086        store
1087    }
1088
1089    #[test]
1090    fn approval_is_bound_to_exact_transaction_and_expires_closed() {
1091        let id = TransactionId::from_bytes([3; 32]);
1092        let other = TransactionId::from_bytes([4; 32]);
1093        let store = policy_complete_store(id, TransactionState::PendingApproval);
1094        let principal = PrincipalId::digest_label("fresh-judge");
1095        let wrong_grant = ApprovalGrant::new(other, principal, 100, 200).unwrap();
1096        assert!(matches!(
1097            store.approve(id, wrong_grant),
1098            Err(TransactionStoreError::ApprovalBindingMismatch {
1099                requested,
1100                bound
1101            }) if requested == id && bound == other
1102        ));
1103
1104        let grant = ApprovalGrant::new(id, principal, 100, 200).unwrap();
1105        let approval_id = grant.id();
1106        let approved = store.approve(id, grant).unwrap();
1107        assert_eq!(approved.approval().unwrap().id(), approval_id);
1108        assert!(matches!(
1109            store.reserve(id, 200),
1110            Err(TransactionStoreError::ApprovalExpired { .. })
1111        ));
1112        assert_eq!(store.get(id).unwrap().state(), TransactionState::Expired);
1113    }
1114
1115    #[test]
1116    fn approval_window_must_be_forward_and_digest_is_deterministic() {
1117        let id = TransactionId::from_bytes([5; 32]);
1118        let principal = PrincipalId::digest_label("judge");
1119        assert!(ApprovalGrant::new(id, principal, 10, 10).is_err());
1120        let first = ApprovalGrant::new(id, principal, 10, 11).unwrap();
1121        let second = ApprovalGrant::new(id, principal, 10, 11).unwrap();
1122        assert_eq!(first.id(), second.id());
1123    }
1124
1125    #[test]
1126    fn atomic_reservation_is_single_use_under_concurrency() {
1127        const CONTENDERS: usize = 8;
1128        let id = TransactionId::from_bytes([6; 32]);
1129        let store = policy_complete_store(id, TransactionState::AutoApproved);
1130        let barrier = Arc::new(Barrier::new(CONTENDERS));
1131        let mut threads = Vec::new();
1132        for _ in 0..CONTENDERS {
1133            let store = store.clone();
1134            let barrier = Arc::clone(&barrier);
1135            threads.push(thread::spawn(move || {
1136                barrier.wait();
1137                store.reserve(id, 0).is_ok()
1138            }));
1139        }
1140        let winners = threads
1141            .into_iter()
1142            .map(|thread| thread.join().unwrap())
1143            .filter(|won| *won)
1144            .count();
1145
1146        assert_eq!(winners, 1);
1147        assert_eq!(store.get(id).unwrap().state(), TransactionState::Reserved);
1148        assert!(matches!(
1149            store.reserve(id, 0),
1150            Err(TransactionStoreError::NotReservable {
1151                actual: TransactionState::Reserved,
1152                ..
1153            })
1154        ));
1155    }
1156
1157    #[cfg(unix)]
1158    #[test]
1159    fn blob_shard_symlink_cannot_redirect_a_write() {
1160        use std::os::unix::fs::symlink;
1161
1162        let (_guard, store) = test_store("shard-symlink");
1163        let outside = std::env::temp_dir().join(format!(
1164            "vsh-store-outside-{}-{}",
1165            std::process::id(),
1166            TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1167        ));
1168        fs::create_dir(&outside).unwrap();
1169        let id = BlobId::digest(b"cannot escape");
1170        let shard = &id.to_string()[..2];
1171        symlink(&outside, store.blobs_dir().join(shard)).unwrap();
1172
1173        assert!(store.put(b"cannot escape").is_err());
1174        assert_eq!(fs::read_dir(&outside).unwrap().count(), 0);
1175
1176        fs::remove_dir(&outside).unwrap();
1177    }
1178
1179    #[test]
1180    fn public_store_errors_have_stable_messages_and_sources() {
1181        let first = TransactionId::from_bytes([1; 32]);
1182        let second = TransactionId::from_bytes([2; 32]);
1183        let transition = TransitionError {
1184            from: TransactionState::Created,
1185            to: TransactionState::Committed,
1186        };
1187        let errors = [
1188            TransactionStoreError::Duplicate { id: first },
1189            TransactionStoreError::NotFound { id: first },
1190            TransactionStoreError::StateConflict {
1191                id: first,
1192                expected: TransactionState::Running,
1193                actual: TransactionState::Created,
1194            },
1195            TransactionStoreError::Transition(transition),
1196            TransactionStoreError::ApprovalBindingMismatch {
1197                requested: first,
1198                bound: second,
1199            },
1200            TransactionStoreError::MissingApproval { id: first },
1201            TransactionStoreError::ApprovalExpired {
1202                id: first,
1203                expired_at_unix_ms: 10,
1204                observed_at_unix_ms: 11,
1205            },
1206            TransactionStoreError::NotReservable {
1207                id: first,
1208                actual: TransactionState::Denied,
1209            },
1210            TransactionStoreError::PersistentIo {
1211                operation: "read",
1212                kind: io::ErrorKind::PermissionDenied,
1213            },
1214            TransactionStoreError::PersistentCorrupt {
1215                offset: 7,
1216                reason: "test",
1217            },
1218            TransactionStoreError::PersistentLogLimit {
1219                observed: 2,
1220                maximum: 1,
1221            },
1222            TransactionStoreError::PersistentRecordLimit {
1223                observed: 2,
1224                maximum: 1,
1225            },
1226            TransactionStoreError::Poisoned,
1227        ];
1228        for error in errors {
1229            assert!(!error.to_string().is_empty());
1230            assert_eq!(
1231                Error::source(&error).is_some(),
1232                matches!(error, TransactionStoreError::Transition(_))
1233            );
1234        }
1235
1236        let expected = BlobId::from_bytes([3; 32]);
1237        let actual = BlobId::from_bytes([4; 32]);
1238        let blob_errors = [
1239            BlobStoreError::Io {
1240                operation: "read",
1241                path: PathBuf::from("blob"),
1242                source: io::Error::other("test"),
1243            },
1244            BlobStoreError::Corrupt {
1245                path: PathBuf::from("blob"),
1246                expected,
1247                actual,
1248            },
1249            BlobStoreError::SizeLimit {
1250                path: PathBuf::from("blob"),
1251                observed: 2,
1252                maximum: 1,
1253            },
1254            BlobStoreError::TemporaryNameExhausted {
1255                directory: PathBuf::from("blobs"),
1256            },
1257        ];
1258        for error in blob_errors {
1259            assert!(!error.to_string().is_empty());
1260            assert_eq!(
1261                Error::source(&error).is_some(),
1262                matches!(error, BlobStoreError::Io { .. })
1263            );
1264        }
1265
1266        let approval =
1267            ApprovalGrant::new(first, PrincipalId::digest_label("test"), 2, 1).unwrap_err();
1268        assert_eq!(
1269            approval.to_string(),
1270            "approval expiry 1 must follow issuance 2"
1271        );
1272    }
1273}