1use 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#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct BlobStore {
38 blobs_dir: PathBuf,
39 directory: DataDirectory,
40}
41
42impl BlobStore {
43 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 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 #[must_use]
81 pub fn blobs_dir(&self) -> &Path {
82 &self.blobs_dir
83 }
84
85 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 pub fn get(&self, id: BlobId) -> Result<Vec<u8>, BlobStoreError> {
165 self.get_bounded(id, usize::MAX)
166 }
167
168 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 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#[derive(Debug)]
331#[non_exhaustive]
332pub enum BlobStoreError {
333 Io {
335 operation: &'static str,
337 path: PathBuf,
339 source: io::Error,
341 },
342 Corrupt {
344 path: PathBuf,
346 expected: BlobId,
348 actual: BlobId,
350 },
351 SizeLimit {
353 path: PathBuf,
355 observed: usize,
357 maximum: usize,
359 },
360 TemporaryNameExhausted {
362 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#[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 #[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 #[must_use]
448 pub const fn with_artifact(mut self, artifact: BlobId) -> Self {
449 self.artifact = Some(artifact);
450 self
451 }
452
453 #[must_use]
455 pub const fn id(&self) -> TransactionId {
456 self.id
457 }
458
459 #[must_use]
461 pub const fn base_snapshot(&self) -> SnapshotId {
462 self.base_snapshot
463 }
464
465 #[must_use]
467 pub const fn state(&self) -> TransactionState {
468 self.state
469 }
470
471 #[must_use]
473 pub const fn artifact(&self) -> Option<BlobId> {
474 self.artifact
475 }
476
477 #[must_use]
479 pub const fn approval(&self) -> Option<ApprovalGrant> {
480 self.approval
481 }
482
483 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
502pub struct ApprovalGrant {
503 binding: ApprovalBinding,
504 id: ApprovalId,
505}
506
507impl ApprovalGrant {
508 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 #[must_use]
539 pub const fn id(self) -> ApprovalId {
540 self.id
541 }
542
543 #[must_use]
545 pub const fn transaction(self) -> TransactionId {
546 self.binding.transaction
547 }
548
549 #[must_use]
551 pub const fn principal(self) -> PrincipalId {
552 self.binding.principal
553 }
554
555 #[must_use]
557 pub const fn issued_at_unix_ms(self) -> u64 {
558 self.binding.issued_at_unix_ms
559 }
560
561 #[must_use]
563 pub const fn expires_at_unix_ms(self) -> u64 {
564 self.binding.expires_at_unix_ms
565 }
566
567 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
576pub enum ApprovalGrantError {
577 InvalidWindow {
579 issued_at_unix_ms: u64,
581 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#[derive(Debug, Eq, PartialEq)]
604pub struct CommitReservation {
605 transaction: TransactionId,
606 base_snapshot: SnapshotId,
607}
608
609impl CommitReservation {
610 #[must_use]
612 pub const fn transaction(&self) -> TransactionId {
613 self.transaction
614 }
615
616 #[must_use]
618 pub const fn base_snapshot(&self) -> SnapshotId {
619 self.base_snapshot
620 }
621}
622
623pub trait TransactionStore: Send + Sync {
625 fn create(&self, record: TransactionRecord) -> Result<(), TransactionStoreError>;
631
632 fn get(&self, id: TransactionId) -> Result<TransactionRecord, TransactionStoreError>;
638
639 fn compare_and_transition(
645 &self,
646 id: TransactionId,
647 expected: TransactionState,
648 next: TransactionState,
649 ) -> Result<TransactionRecord, TransactionStoreError>;
650
651 fn approve(
657 &self,
658 id: TransactionId,
659 grant: ApprovalGrant,
660 ) -> Result<TransactionRecord, TransactionStoreError>;
661
662 fn reserve(
669 &self,
670 id: TransactionId,
671 now_unix_ms: u64,
672 ) -> Result<CommitReservation, TransactionStoreError>;
673}
674
675#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
806#[non_exhaustive]
807pub enum TransactionStoreError {
808 Duplicate {
810 id: TransactionId,
812 },
813 NotFound {
815 id: TransactionId,
817 },
818 StateConflict {
820 id: TransactionId,
822 expected: TransactionState,
824 actual: TransactionState,
826 },
827 Transition(TransitionError),
829 ApprovalBindingMismatch {
831 requested: TransactionId,
833 bound: TransactionId,
835 },
836 MissingApproval {
838 id: TransactionId,
840 },
841 ApprovalExpired {
843 id: TransactionId,
845 expired_at_unix_ms: u64,
847 observed_at_unix_ms: u64,
849 },
850 NotReservable {
852 id: TransactionId,
854 actual: TransactionState,
856 },
857 PersistentIo {
859 operation: &'static str,
861 kind: io::ErrorKind,
863 },
864 PersistentCorrupt {
866 offset: u64,
868 reason: &'static str,
870 },
871 PersistentLogLimit {
873 observed: u64,
875 maximum: u64,
877 },
878 PersistentRecordLimit {
880 observed: usize,
882 maximum: usize,
884 },
885 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}