1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, MutexGuard};
6use std::time::Instant;
7
8use vsh_commit::{
9 CommitConfig, CommitError, CommitPlan, CommitPlanError, CommitReceipt, Committer,
10 RecoveryReport, SnapshotLimits,
11};
12use vsh_monty::{
13 ExecutionError, ExecutionLimits, ExecutionOutcome, ExecutionStats, InProcessConfig,
14 InProcessMonty, MontyObject, ResultCompatibility, ResultCompatibilityError, SubprocessConfig,
15 SubprocessMonty, VirtualRoot, validate_result_compatibility,
16};
17use vsh_policy::{
18 DenyManifest, PolicyDecision, PolicyInput, PolicyProfile, RiskManifest,
19 TransactionIdentityInput, TransactionPolicy, bind_transaction,
20};
21use vsh_store::{
22 ApprovalGrant, ApprovalGrantError, BlobStore, BlobStoreError, DataDirectory,
23 DataDirectoryError, FileStoreConfig, FileTransactionStore, TransactionRecord, TransactionStore,
24 TransactionStoreError,
25};
26use vsh_types::{
27 DiffDigest, DiffEntry, RuntimeConfigDigest, SnapshotId, TransactionId, TransactionState,
28};
29use vsh_vfs::{VfsError, VirtualFs};
30
31use crate::artifact::{ArtifactError, PendingTransaction, decode_pending, encode_pending};
32
33pub type ExecutionBudget = ExecutionLimits;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct ArtifactLimits {
39 pub max_bytes: usize,
41 pub max_value_bytes: usize,
43 pub max_stdout_bytes: usize,
45 pub max_entries: usize,
47 pub max_dependencies: usize,
49 pub max_path_bytes: usize,
51 pub max_ephemeral_entries: usize,
53 pub max_ephemeral_bytes: usize,
55}
56
57impl Default for ArtifactLimits {
58 fn default() -> Self {
59 Self {
60 max_bytes: 128 * 1024 * 1024,
61 max_value_bytes: 16 * 1024 * 1024,
62 max_stdout_bytes: 16 * 1024 * 1024,
63 max_entries: 100_000,
64 max_dependencies: 250_000,
65 max_path_bytes: 16 * 1024,
66 max_ephemeral_entries: 64,
67 max_ephemeral_bytes: 128 * 1024 * 1024,
68 }
69 }
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub enum RunMode {
75 #[default]
77 Preview,
78 Auto,
80}
81
82#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
84pub enum ReceiptDetail {
85 #[default]
87 Compact,
88 Full,
90}
91
92#[derive(Clone, Copy, Debug)]
94pub struct RunRequest<'a> {
95 pub code: &'a str,
97 pub intent: Option<&'a str>,
99 pub mode: RunMode,
101 pub detail: ReceiptDetail,
103 pub budget: ExecutionBudget,
105}
106
107impl<'a> RunRequest<'a> {
108 #[must_use]
110 pub fn new(code: &'a str) -> Self {
111 Self {
112 code,
113 intent: None,
114 mode: RunMode::Preview,
115 detail: ReceiptDetail::Compact,
116 budget: ExecutionBudget::default(),
117 }
118 }
119
120 #[must_use]
122 pub const fn with_intent(mut self, intent: &'a str) -> Self {
123 self.intent = Some(intent);
124 self
125 }
126
127 #[must_use]
129 pub const fn with_mode(mut self, mode: RunMode) -> Self {
130 self.mode = mode;
131 self
132 }
133
134 #[must_use]
136 pub const fn with_detail(mut self, detail: ReceiptDetail) -> Self {
137 self.detail = detail;
138 self
139 }
140
141 #[must_use]
143 pub const fn with_budget(mut self, budget: ExecutionBudget) -> Self {
144 self.budget = budget;
145 self
146 }
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum RuntimeDecision {
152 Denied(DenyManifest),
154 AutoApproved,
156 PendingApproval(RiskManifest),
158}
159
160#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162pub struct StageTimings {
163 pub snapshot_ns: u64,
165 pub execute_ns: u64,
167 pub diff_ns: u64,
169 pub policy_ns: u64,
171 pub bind_and_store_ns: u64,
173 pub commit_ns: u64,
175 pub total_ns: u64,
177}
178
179#[derive(Clone, Debug)]
181pub struct Receipt {
182 pub transaction: TransactionId,
184 pub base_snapshot: SnapshotId,
186 pub state: TransactionState,
188 pub decision: RuntimeDecision,
190 pub diff: DiffDigest,
192 pub changed_paths: usize,
194 pub changes: Vec<DiffEntry>,
196 pub value: MontyObject,
198 pub stdout: String,
200 pub execution: ExecutionStats,
202 pub timings: StageTimings,
204 pub commit: Option<CommitReceipt>,
206}
207
208#[derive(Clone, Debug)]
210pub struct RuntimeConfig {
211 workspace_root: PathBuf,
212 data_directory: PathBuf,
213 data_directory_authority: DataDirectoryAuthority,
214 worker_path: Option<PathBuf>,
215 max_idle_workers: usize,
216 result_compatibility: ResultCompatibility,
217 virtual_root: VirtualRoot,
218 policy: TransactionPolicy,
219 snapshot_limits: SnapshotLimits,
220 commit_config: CommitConfig,
221 store_config: FileStoreConfig,
222 artifact_limits: ArtifactLimits,
223}
224
225#[derive(Clone, Copy, Debug, Eq, PartialEq)]
226enum DataDirectoryAuthority {
227 WorkspaceProtected,
228 TrustedExternal,
229}
230
231impl RuntimeConfig {
232 #[must_use]
237 pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
238 let workspace_root = workspace_root.into();
239 let data_directory = workspace_root.join(".vsh-runtime").join("data");
240 Self {
241 workspace_root,
242 data_directory,
243 data_directory_authority: DataDirectoryAuthority::WorkspaceProtected,
244 worker_path: Some(default_worker_path()),
245 max_idle_workers: 4,
246 result_compatibility: ResultCompatibility::Native,
247 virtual_root: VirtualRoot::default(),
248 policy: TransactionPolicy::default(),
249 snapshot_limits: SnapshotLimits::default(),
250 commit_config: CommitConfig::default(),
251 store_config: FileStoreConfig::default(),
252 artifact_limits: ArtifactLimits::default(),
253 }
254 }
255
256 #[must_use]
258 pub fn with_data_directory(mut self, data_directory: impl Into<PathBuf>) -> Self {
259 self.data_directory = data_directory.into();
260 self.data_directory_authority = DataDirectoryAuthority::TrustedExternal;
261 self
262 }
263
264 #[must_use]
266 pub fn with_worker_path(mut self, worker_path: impl Into<PathBuf>) -> Self {
267 self.worker_path = Some(worker_path.into());
268 self
269 }
270
271 #[must_use]
273 pub const fn with_max_idle_workers(mut self, max_idle_workers: usize) -> Self {
274 self.max_idle_workers = max_idle_workers;
275 self
276 }
277
278 #[must_use]
280 pub const fn with_result_compatibility(
281 mut self,
282 result_compatibility: ResultCompatibility,
283 ) -> Self {
284 self.result_compatibility = result_compatibility;
285 self
286 }
287
288 #[must_use]
293 pub fn with_in_process_execution(mut self) -> Self {
294 self.worker_path = None;
295 self
296 }
297
298 #[must_use]
300 pub fn with_virtual_root(mut self, virtual_root: VirtualRoot) -> Self {
301 self.virtual_root = virtual_root;
302 self
303 }
304
305 #[must_use]
307 pub fn with_policy(mut self, policy: TransactionPolicy) -> Self {
308 self.policy = policy;
309 self
310 }
311
312 #[must_use]
314 pub fn with_policy_profile(self, profile: PolicyProfile) -> Self {
315 self.with_policy(TransactionPolicy::preset(profile))
316 }
317
318 #[must_use]
320 pub const fn with_snapshot_limits(mut self, limits: SnapshotLimits) -> Self {
321 self.snapshot_limits = limits;
322 self
323 }
324
325 #[must_use]
327 pub const fn with_commit_config(mut self, config: CommitConfig) -> Self {
328 self.commit_config = config;
329 self
330 }
331
332 #[must_use]
334 pub const fn with_store_config(mut self, config: FileStoreConfig) -> Self {
335 self.store_config = config;
336 self
337 }
338
339 #[must_use]
341 pub const fn with_artifact_limits(mut self, limits: ArtifactLimits) -> Self {
342 self.artifact_limits = limits;
343 self
344 }
345
346 #[must_use]
348 pub fn workspace_root(&self) -> &Path {
349 &self.workspace_root
350 }
351
352 #[must_use]
354 pub fn data_directory(&self) -> &Path {
355 &self.data_directory
356 }
357
358 #[must_use]
360 pub fn worker_path(&self) -> Option<&Path> {
361 self.worker_path.as_deref()
362 }
363
364 #[must_use]
366 pub const fn policy(&self) -> &TransactionPolicy {
367 &self.policy
368 }
369}
370
371fn default_worker_path() -> PathBuf {
372 std::env::var_os("VSH_MONTY_WORKER")
373 .filter(|path| !path.is_empty())
374 .map_or_else(|| PathBuf::from("vsh-monty-worker"), PathBuf::from)
375}
376
377enum RuntimeExecution {
378 Subprocess(Box<SubprocessMonty>),
379 InProcess,
380}
381
382impl RuntimeExecution {
383 fn open(config: &RuntimeConfig) -> Result<Self, ExecutionError> {
384 let Some(worker_path) = &config.worker_path else {
385 return Ok(Self::InProcess);
386 };
387 let adapter = InProcessConfig::new(config.virtual_root.clone())
388 .with_call_policy(config.policy.call_policy().clone());
389 let worker = SubprocessMonty::new(
390 SubprocessConfig::new(worker_path, adapter)
391 .with_max_idle_workers(config.max_idle_workers),
392 )?;
393 Ok(Self::Subprocess(Box::new(worker)))
394 }
395
396 fn security_digest(&self, adapter: &InProcessConfig) -> RuntimeConfigDigest {
397 match self {
398 Self::Subprocess(worker) => worker.config().security_digest_for(adapter),
399 Self::InProcess => adapter.security_digest(),
400 }
401 }
402
403 fn execute(
404 &self,
405 code: &str,
406 filesystem: &mut VirtualFs,
407 adapter: &InProcessConfig,
408 ) -> Result<ExecutionOutcome, ExecutionError> {
409 match self {
410 Self::Subprocess(worker) => worker.execute_with_config(code, filesystem, adapter),
411 Self::InProcess => InProcessMonty::new(adapter.clone()).execute(code, filesystem),
412 }
413 }
414}
415
416pub struct Runtime {
418 config: RuntimeConfig,
419 execution: RuntimeExecution,
420 committer: Committer,
421 store: FileTransactionStore,
422 artifacts: BlobStore,
423 pending: Mutex<PendingArtifacts>,
424 startup_recovery: RecoveryReport,
425}
426
427#[derive(Default)]
428struct PendingArtifacts {
429 entries: BTreeMap<TransactionId, (PendingTransaction, usize)>,
430 encoded_bytes: usize,
431}
432
433impl Runtime {
434 pub fn open(config: RuntimeConfig) -> Result<Self, VshError> {
441 let (committer, data_directory) = match config.data_directory_authority {
442 DataDirectoryAuthority::WorkspaceProtected => {
443 Committer::open_with_workspace_data(&config.workspace_root, config.commit_config)?
444 }
445 DataDirectoryAuthority::TrustedExternal => {
446 validate_disjoint_data_directory(&config.workspace_root, &config.data_directory)?;
447 let data_directory = DataDirectory::open_trusted(&config.data_directory)?;
448 validate_canonical_data_directory_separation(
449 &config.workspace_root,
450 data_directory.path(),
451 )?;
452 let artifacts = BlobStore::open_in(&data_directory)?;
453 let committer =
454 Committer::open(&config.workspace_root, artifacts, config.commit_config)?;
455 (committer, data_directory)
456 }
457 };
458 let artifacts = committer.artifact_store();
459 let store = FileTransactionStore::open_in(&data_directory, config.store_config)?;
460 let execution = RuntimeExecution::open(&config)?;
461 let startup_recovery = committer.recover(&store)?;
462 if !startup_recovery.conflicts.is_empty() {
463 return Err(VshError::RecoveryConflicts(Box::new(startup_recovery)));
464 }
465 Ok(Self {
466 config,
467 execution,
468 committer,
469 store,
470 artifacts,
471 pending: Mutex::new(PendingArtifacts::default()),
472 startup_recovery,
473 })
474 }
475
476 #[must_use]
478 pub const fn startup_recovery(&self) -> &RecoveryReport {
479 &self.startup_recovery
480 }
481
482 pub fn run(&self, request: RunRequest<'_>) -> Result<Receipt, VshError> {
490 validate_program_size(request.code, request.budget)?;
491 let total_started = Instant::now();
492 let (mut filesystem, base_snapshot, base_node_count, snapshot_ns) =
493 self.snapshot_filesystem()?;
494
495 let monty_config = self.monty_config(request.budget);
496 let runtime_config = aggregate_runtime_digest(
497 self.execution.security_digest(&monty_config),
498 self.config.snapshot_limits,
499 self.config.commit_config,
500 self.config.store_config,
501 self.config.artifact_limits,
502 self.config.result_compatibility,
503 );
504 let execute_started = Instant::now();
505 let ExecutionOutcome {
506 value,
507 stdout,
508 stats,
509 denied_accesses,
510 } = self
511 .execution
512 .execute(request.code, &mut filesystem, &monty_config)?;
513 validate_result_compatibility(&value, self.config.result_compatibility)?;
514 let execute_ns = elapsed_ns(execute_started);
515
516 let diff_started = Instant::now();
517 let diff = filesystem.canonical_diff()?;
518 let diff_ns = elapsed_ns(diff_started);
519
520 let policy_started = Instant::now();
521 let policy_decision = self.config.policy.evaluate(PolicyInput {
522 diff: &diff,
523 effects: filesystem.effects(),
524 denied_accesses: &denied_accesses,
525 base_node_count,
526 });
527 let policy_ns = elapsed_ns(policy_started);
528
529 let bind_started = Instant::now();
530 let binding = bind_transaction(TransactionIdentityInput {
531 base_snapshot,
532 diff: &diff,
533 read_set: filesystem.read_set(),
534 write_set: filesystem.write_set(),
535 program: request.code,
536 policy: &self.config.policy,
537 runtime_config,
538 intent: request.intent,
539 });
540 let transaction = binding.transaction_id();
541 let (decision, state, record) =
542 Self::policy_record(transaction, base_snapshot, policy_decision)?;
543 let changed_paths = diff.entries().len();
544 let changes = receipt_changes(request.detail, &diff);
545 let mut receipt = Receipt {
546 transaction,
547 base_snapshot,
548 state,
549 decision,
550 diff: diff.digest(),
551 changed_paths,
552 changes,
553 value,
554 stdout,
555 execution: stats,
556 timings: StageTimings {
557 snapshot_ns,
558 execute_ns,
559 diff_ns,
560 policy_ns,
561 bind_and_store_ns: elapsed_ns(bind_started),
562 commit_ns: 0,
563 total_ns: elapsed_ns(total_started),
564 },
565 commit: None,
566 };
567
568 if state == TransactionState::Denied {
569 self.store.create(record)?;
570 receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
571 receipt.timings.total_ns = elapsed_ns(total_started);
572 } else {
573 receipt = self.store_pending(
574 record,
575 PendingTransaction {
576 binding,
577 diff,
578 read_set: filesystem.read_set().clone(),
579 write_set: filesystem.write_set().clone(),
580 receipt,
581 },
582 request.mode,
583 bind_started,
584 total_started,
585 )?;
586 }
587
588 if request.mode == RunMode::Auto && state == TransactionState::AutoApproved {
589 receipt = self.commit(transaction, 0)?;
590 receipt.timings.total_ns = elapsed_ns(total_started);
591 }
592 Ok(receipt)
593 }
594
595 pub fn preview(&self, mut request: RunRequest<'_>) -> Result<Receipt, VshError> {
601 request.mode = RunMode::Preview;
602 self.run(request)
603 }
604
605 pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
614 self.remove_pending(transaction)
615 .map(|artifact| artifact.is_some())
616 }
617
618 pub fn approve(
625 &self,
626 transaction: TransactionId,
627 principal: vsh_types::PrincipalId,
628 issued_at_unix_ms: u64,
629 expires_at_unix_ms: u64,
630 ) -> Result<TransactionRecord, VshError> {
631 self.load_pending(transaction)?;
632 let grant = ApprovalGrant::new(
633 transaction,
634 principal,
635 issued_at_unix_ms,
636 expires_at_unix_ms,
637 )?;
638 let record = self.store.approve(transaction, grant)?;
639 if let Some((artifact, _)) = self.pending()?.entries.get_mut(&transaction) {
640 artifact.receipt.state = TransactionState::Approved;
641 }
642 Ok(record)
643 }
644
645 pub fn commit(
652 &self,
653 transaction: TransactionId,
654 now_unix_ms: u64,
655 ) -> Result<Receipt, VshError> {
656 let artifact = self.load_pending(transaction)?;
657 validate_result_compatibility(&artifact.receipt.value, self.config.result_compatibility)?;
658 self.persist_ephemeral(&artifact)?;
659 let plan = CommitPlan::new(
660 &artifact.binding,
661 &artifact.diff,
662 &artifact.read_set,
663 &artifact.write_set,
664 )?;
665 let reservation = self.store.reserve(transaction, now_unix_ms)?;
666 let commit_started = Instant::now();
667 let commit = self.committer.commit(&self.store, reservation, &plan);
668 let commit_ns = elapsed_ns(commit_started);
669 self.remove_pending(transaction)?;
670 let commit = commit?;
671 let mut receipt = artifact.receipt;
672 receipt.state = TransactionState::Committed;
673 receipt.timings.commit_ns = commit_ns;
674 receipt.timings.total_ns = receipt.timings.total_ns.saturating_add(commit_ns);
675 receipt.commit = Some(commit);
676 Ok(receipt)
677 }
678
679 pub fn recover(&self) -> Result<RecoveryReport, VshError> {
685 self.committer.recover(&self.store).map_err(Into::into)
686 }
687
688 pub fn transaction(&self, transaction: TransactionId) -> Result<TransactionRecord, VshError> {
694 match self.store.get(transaction) {
695 Ok(record) => Ok(record),
696 Err(TransactionStoreError::NotFound { id }) if id == transaction => {
697 let artifact = self
698 .pending()?
699 .entries
700 .get(&transaction)
701 .map(|(artifact, _)| artifact.clone())
702 .ok_or(TransactionStoreError::NotFound { id })?;
703 Self::ephemeral_record(&artifact)
704 }
705 Err(source) => Err(source.into()),
706 }
707 }
708
709 fn monty_config(&self, budget: ExecutionBudget) -> InProcessConfig {
710 InProcessConfig::new(self.config.virtual_root.clone())
711 .with_limits(budget)
712 .with_call_policy(self.config.policy.call_policy().clone())
713 }
714
715 fn snapshot_filesystem(&self) -> Result<(VirtualFs, SnapshotId, usize, u64), VshError> {
716 let started = Instant::now();
717 let snapshot = self.committer.snapshot(self.config.snapshot_limits)?;
718 let id = snapshot.id();
719 let nodes = snapshot.len();
720 Ok((VirtualFs::new(snapshot), id, nodes, elapsed_ns(started)))
721 }
722
723 fn insert_pending(
724 &self,
725 artifact: PendingTransaction,
726 encoded_bytes: usize,
727 ) -> Result<(), VshError> {
728 let transaction = artifact.binding.transaction_id();
729 let mut pending = self.pending()?;
730 let entries = pending.entries.len();
731 let retained_bytes = pending.encoded_bytes;
732 let attempted_bytes = retained_bytes.saturating_add(encoded_bytes);
733 if entries >= self.config.artifact_limits.max_ephemeral_entries
734 || attempted_bytes > self.config.artifact_limits.max_ephemeral_bytes
735 {
736 return Err(VshError::EphemeralCapacity {
737 entries,
738 max_entries: self.config.artifact_limits.max_ephemeral_entries,
739 attempted_bytes,
740 max_bytes: self.config.artifact_limits.max_ephemeral_bytes,
741 });
742 }
743 if pending.entries.contains_key(&transaction) {
744 return Err(VshError::DuplicatePending { transaction });
745 }
746 pending
747 .entries
748 .insert(transaction, (artifact, encoded_bytes));
749 pending.encoded_bytes = attempted_bytes;
750 Ok(())
751 }
752
753 fn remove_pending(
754 &self,
755 transaction: TransactionId,
756 ) -> Result<Option<PendingTransaction>, VshError> {
757 let mut pending = self.pending()?;
758 let Some((artifact, encoded_bytes)) = pending.entries.remove(&transaction) else {
759 return Ok(None);
760 };
761 pending.encoded_bytes = pending.encoded_bytes.saturating_sub(encoded_bytes);
762 Ok(Some(artifact))
763 }
764
765 fn persist_pending(
766 &self,
767 record: TransactionRecord,
768 mut artifact: PendingTransaction,
769 bind_started: Instant,
770 total_started: Instant,
771 ) -> Result<Receipt, VshError> {
772 let encoded = encode_pending(&artifact, self.config.artifact_limits)?;
773 let artifact_id = self.artifacts.put(&encoded)?;
774 self.store.create(record.with_artifact(artifact_id))?;
775 artifact.receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
776 artifact.receipt.timings.total_ns = elapsed_ns(total_started);
777 let receipt = artifact.receipt.clone();
778 Ok(receipt)
779 }
780
781 fn store_pending(
782 &self,
783 record: TransactionRecord,
784 artifact: PendingTransaction,
785 mode: RunMode,
786 bind_started: Instant,
787 total_started: Instant,
788 ) -> Result<Receipt, VshError> {
789 if mode == RunMode::Preview && artifact.receipt.state == TransactionState::AutoApproved {
790 self.retain_ephemeral(artifact, bind_started, total_started)
791 } else {
792 self.persist_pending(record, artifact, bind_started, total_started)
793 }
794 }
795
796 fn retain_ephemeral(
797 &self,
798 mut artifact: PendingTransaction,
799 bind_started: Instant,
800 total_started: Instant,
801 ) -> Result<Receipt, VshError> {
802 let encoded = encode_pending(&artifact, self.config.artifact_limits)?;
803 artifact.receipt.timings.bind_and_store_ns = elapsed_ns(bind_started);
804 artifact.receipt.timings.total_ns = elapsed_ns(total_started);
805 let receipt = artifact.receipt.clone();
806 self.insert_pending(artifact, encoded.len())?;
807 Ok(receipt)
808 }
809
810 fn persist_ephemeral(&self, artifact: &PendingTransaction) -> Result<(), VshError> {
811 let transaction = artifact.binding.transaction_id();
812 match self.store.get(transaction) {
813 Ok(_) => return Ok(()),
814 Err(TransactionStoreError::NotFound { id }) if id == transaction => {}
815 Err(source) => return Err(source.into()),
816 }
817 let encoded = encode_pending(artifact, self.config.artifact_limits)?;
818 let artifact_id = self.artifacts.put(&encoded)?;
819 let record = Self::ephemeral_record(artifact)?.with_artifact(artifact_id);
820 self.store.create(record)?;
821 Ok(())
822 }
823
824 fn ephemeral_record(artifact: &PendingTransaction) -> Result<TransactionRecord, VshError> {
825 if artifact.receipt.state != TransactionState::AutoApproved
826 || !matches!(&artifact.receipt.decision, RuntimeDecision::AutoApproved)
827 {
828 return Err(VshError::MissingPending {
829 transaction: artifact.binding.transaction_id(),
830 });
831 }
832 let mut record = TransactionRecord::new(
833 artifact.binding.transaction_id(),
834 artifact.binding.base_snapshot,
835 );
836 for state in [
837 TransactionState::Running,
838 TransactionState::VirtualComplete,
839 TransactionState::AutoApproved,
840 ] {
841 record
842 .transition(state)
843 .map_err(TransactionStoreError::Transition)?;
844 }
845 Ok(record)
846 }
847
848 fn load_pending(&self, transaction: TransactionId) -> Result<PendingTransaction, VshError> {
849 if let Some(artifact) = self
850 .pending()?
851 .entries
852 .get(&transaction)
853 .map(|(artifact, _)| artifact.clone())
854 {
855 return Ok(artifact);
856 }
857 let record = self.store.get(transaction)?;
858 let artifact_id = record
859 .artifact()
860 .ok_or(VshError::MissingPending { transaction })?;
861 let bytes = self
862 .artifacts
863 .get_bounded(artifact_id, self.config.artifact_limits.max_bytes)?;
864 let mut artifact = decode_pending(&bytes, self.config.artifact_limits)?;
865 let actual = artifact.binding.transaction_id();
866 if actual != transaction || artifact.binding.base_snapshot != record.base_snapshot() {
867 return Err(VshError::ArtifactBinding {
868 requested: transaction,
869 decoded: actual,
870 });
871 }
872 artifact.receipt.state = record.state();
873 Ok(artifact)
874 }
875
876 fn pending(&self) -> Result<MutexGuard<'_, PendingArtifacts>, VshError> {
877 self.pending.lock().map_err(|_| VshError::PendingPoisoned)
878 }
879
880 fn policy_record(
881 transaction: TransactionId,
882 base_snapshot: SnapshotId,
883 decision: PolicyDecision,
884 ) -> Result<(RuntimeDecision, TransactionState, TransactionRecord), VshError> {
885 let mut record = TransactionRecord::new(transaction, base_snapshot);
886 record
887 .transition(TransactionState::Running)
888 .map_err(TransactionStoreError::Transition)?;
889 record
890 .transition(TransactionState::VirtualComplete)
891 .map_err(TransactionStoreError::Transition)?;
892 let (decision, state) = match decision {
893 PolicyDecision::Deny(manifest) => {
894 record
895 .transition(TransactionState::Denied)
896 .map_err(TransactionStoreError::Transition)?;
897 (RuntimeDecision::Denied(manifest), TransactionState::Denied)
898 }
899 PolicyDecision::AutoApprove => {
900 record
901 .transition(TransactionState::AutoApproved)
902 .map_err(TransactionStoreError::Transition)?;
903 (
904 RuntimeDecision::AutoApproved,
905 TransactionState::AutoApproved,
906 )
907 }
908 PolicyDecision::Escalate(manifest) => {
909 record
910 .transition(TransactionState::PendingApproval)
911 .map_err(TransactionStoreError::Transition)?;
912 (
913 RuntimeDecision::PendingApproval(manifest),
914 TransactionState::PendingApproval,
915 )
916 }
917 };
918 Ok((decision, state, record))
919 }
920}
921
922fn aggregate_runtime_digest(
923 monty: RuntimeConfigDigest,
924 snapshot: SnapshotLimits,
925 commit: CommitConfig,
926 store: FileStoreConfig,
927 artifact: ArtifactLimits,
928 result_compatibility: ResultCompatibility,
929) -> RuntimeConfigDigest {
930 let mut canonical = Vec::with_capacity(33 + 8 * 19);
931 canonical.extend_from_slice(b"vsh-runtime-config-v4");
932 canonical.extend_from_slice(monty.as_bytes());
933 encode_usize(snapshot.max_nodes, &mut canonical);
934 encode_usize(snapshot.max_depth, &mut canonical);
935 canonical.extend_from_slice(&snapshot.max_total_file_bytes.to_le_bytes());
936 encode_usize(commit.max_operations, &mut canonical);
937 encode_usize(commit.max_dependencies, &mut canonical);
938 encode_usize(commit.max_path_bytes, &mut canonical);
939 encode_usize(commit.max_plan_bytes, &mut canonical);
940 encode_usize(commit.max_journal_bytes, &mut canonical);
941 encode_usize(commit.max_conflicts, &mut canonical);
942 canonical.extend_from_slice(&store.max_log_bytes.to_le_bytes());
943 encode_usize(store.max_records, &mut canonical);
944 encode_usize(artifact.max_bytes, &mut canonical);
945 encode_usize(artifact.max_value_bytes, &mut canonical);
946 encode_usize(artifact.max_stdout_bytes, &mut canonical);
947 encode_usize(artifact.max_entries, &mut canonical);
948 encode_usize(artifact.max_dependencies, &mut canonical);
949 encode_usize(artifact.max_path_bytes, &mut canonical);
950 encode_usize(artifact.max_ephemeral_entries, &mut canonical);
951 encode_usize(artifact.max_ephemeral_bytes, &mut canonical);
952 canonical.push(match result_compatibility {
953 ResultCompatibility::Native => 0,
954 ResultCompatibility::Python => 1,
955 });
956 RuntimeConfigDigest::digest_canonical(&canonical)
957}
958
959fn encode_usize(value: usize, output: &mut Vec<u8>) {
960 output.extend_from_slice(&u64::try_from(value).unwrap_or(u64::MAX).to_le_bytes());
961}
962
963fn receipt_changes(detail: ReceiptDetail, diff: &vsh_vfs::CanonicalDiff) -> Vec<DiffEntry> {
964 if detail == ReceiptDetail::Full {
965 diff.entries().to_vec()
966 } else {
967 Vec::new()
968 }
969}
970
971fn elapsed_ns(started: Instant) -> u64 {
972 u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX)
973}
974
975fn validate_program_size(code: &str, budget: ExecutionBudget) -> Result<(), ExecutionError> {
976 let attempted = u64::try_from(code.len()).unwrap_or(u64::MAX);
977 let limit = u64::try_from(budget.max_program_bytes).unwrap_or(u64::MAX);
978 if attempted > limit {
979 Err(ExecutionError::Limit(Box::new(
980 vsh_monty::ExecutionLimitExceeded::ProgramBytes { limit, attempted },
981 )))
982 } else {
983 Ok(())
984 }
985}
986
987fn validate_disjoint_data_directory(
988 workspace_root: &Path,
989 data_directory: &Path,
990) -> Result<(), VshError> {
991 let workspace = lexical_absolute(workspace_root);
992 let data = lexical_absolute(data_directory);
993 let canonical_workspace = std::fs::canonicalize(workspace_root).ok();
994 let prospective_data = canonicalize_prospective_path(data_directory);
995 if workspace
996 .as_deref()
997 .zip(data.as_deref())
998 .is_none_or(|(workspace, data)| paths_overlap(workspace, data))
999 || canonical_workspace
1000 .as_deref()
1001 .zip(prospective_data.as_deref())
1002 .is_none_or(|(workspace, data)| paths_overlap(workspace, data))
1003 {
1004 return Err(VshError::UnsafeDataDirectory {
1005 workspace_root: workspace_root.to_path_buf(),
1006 data_directory: data_directory.to_path_buf(),
1007 });
1008 }
1009 Ok(())
1010}
1011
1012fn validate_canonical_data_directory_separation(
1013 workspace_root: &Path,
1014 data_directory: &Path,
1015) -> Result<(), VshError> {
1016 let Ok(workspace) = std::fs::canonicalize(workspace_root) else {
1017 return Err(VshError::UnsafeDataDirectory {
1018 workspace_root: workspace_root.to_path_buf(),
1019 data_directory: data_directory.to_path_buf(),
1020 });
1021 };
1022 let Ok(data) = std::fs::canonicalize(data_directory) else {
1023 return Err(VshError::UnsafeDataDirectory {
1024 workspace_root: workspace_root.to_path_buf(),
1025 data_directory: data_directory.to_path_buf(),
1026 });
1027 };
1028 if paths_overlap(&workspace, &data) {
1029 return Err(VshError::UnsafeDataDirectory {
1030 workspace_root: workspace_root.to_path_buf(),
1031 data_directory: data_directory.to_path_buf(),
1032 });
1033 }
1034 Ok(())
1035}
1036
1037fn canonicalize_prospective_path(path: &Path) -> Option<PathBuf> {
1038 let absolute = lexical_absolute(path)?;
1039 let mut existing = absolute.as_path();
1040 let mut missing = Vec::new();
1041 loop {
1042 match std::fs::canonicalize(existing) {
1043 Ok(mut canonical) => {
1044 for component in missing.iter().rev() {
1045 canonical.push(component);
1046 }
1047 return Some(canonical);
1048 }
1049 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1050 missing.push(existing.file_name()?.to_owned());
1051 existing = existing.parent()?;
1052 }
1053 Err(_) => return None,
1054 }
1055 }
1056}
1057
1058fn lexical_absolute(path: &Path) -> Option<PathBuf> {
1059 let absolute = if path.is_absolute() {
1060 path.to_path_buf()
1061 } else {
1062 std::env::current_dir().ok()?.join(path)
1063 };
1064 let mut normalized = PathBuf::new();
1065 for component in absolute.components() {
1066 match component {
1067 std::path::Component::CurDir => {}
1068 std::path::Component::ParentDir => {
1069 if !normalized.pop() {
1070 return None;
1071 }
1072 }
1073 _ => normalized.push(component.as_os_str()),
1074 }
1075 }
1076 Some(normalized)
1077}
1078
1079fn paths_overlap(left: &Path, right: &Path) -> bool {
1080 left.starts_with(right) || right.starts_with(left)
1081}
1082
1083#[derive(Debug)]
1085#[non_exhaustive]
1086pub enum VshError {
1087 DataDirectory(DataDirectoryError),
1089 Blob(BlobStoreError),
1091 Commit(CommitError),
1093 Execution(ExecutionError),
1095 Vfs(VfsError),
1097 Store(TransactionStoreError),
1099 Approval(ApprovalGrantError),
1101 CommitPlan(CommitPlanError),
1103 Artifact(ArtifactError),
1105 ResultCompatibility(ResultCompatibilityError),
1107 UnsafeDataDirectory {
1109 workspace_root: PathBuf,
1111 data_directory: PathBuf,
1113 },
1114 ArtifactBinding {
1116 requested: TransactionId,
1118 decoded: TransactionId,
1120 },
1121 RecoveryConflicts(Box<RecoveryReport>),
1123 MissingPending {
1125 transaction: TransactionId,
1127 },
1128 DuplicatePending {
1130 transaction: TransactionId,
1132 },
1133 EphemeralCapacity {
1135 entries: usize,
1137 max_entries: usize,
1139 attempted_bytes: usize,
1141 max_bytes: usize,
1143 },
1144 PendingPoisoned,
1146}
1147
1148impl fmt::Display for VshError {
1149 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1150 match self {
1151 Self::DataDirectory(source) => fmt::Display::fmt(source, formatter),
1152 Self::Blob(source) => fmt::Display::fmt(source, formatter),
1153 Self::Commit(source) => fmt::Display::fmt(source, formatter),
1154 Self::Execution(source) => fmt::Display::fmt(source, formatter),
1155 Self::Vfs(source) => fmt::Display::fmt(source, formatter),
1156 Self::Store(source) => fmt::Display::fmt(source, formatter),
1157 Self::Approval(source) => fmt::Display::fmt(source, formatter),
1158 Self::CommitPlan(source) => fmt::Display::fmt(source, formatter),
1159 Self::Artifact(source) => fmt::Display::fmt(source, formatter),
1160 Self::ResultCompatibility(source) => fmt::Display::fmt(source, formatter),
1161 Self::UnsafeDataDirectory {
1162 workspace_root,
1163 data_directory,
1164 } => write!(
1165 formatter,
1166 "trusted data directory {} must be disjoint from workspace {}",
1167 data_directory.display(),
1168 workspace_root.display()
1169 ),
1170 Self::ArtifactBinding { requested, decoded } => write!(
1171 formatter,
1172 "pending artifact for {requested} decodes to transaction {decoded}"
1173 ),
1174 Self::RecoveryConflicts(report) => write!(
1175 formatter,
1176 "startup recovery left {} ambiguous transaction(s)",
1177 report.conflicts.len()
1178 ),
1179 Self::MissingPending { transaction } => {
1180 write!(
1181 formatter,
1182 "no durable pending artifact for transaction {transaction}"
1183 )
1184 }
1185 Self::DuplicatePending { transaction } => {
1186 write!(
1187 formatter,
1188 "pending artifact already exists for {transaction}"
1189 )
1190 }
1191 Self::EphemeralCapacity {
1192 entries,
1193 max_entries,
1194 attempted_bytes,
1195 max_bytes,
1196 } => write!(
1197 formatter,
1198 "process-local preview capacity exceeded: {entries}/{max_entries} entries, \
1199 {attempted_bytes}/{max_bytes} encoded bytes"
1200 ),
1201 Self::PendingPoisoned => formatter.write_str("pending artifact lock was poisoned"),
1202 }
1203 }
1204}
1205
1206impl Error for VshError {
1207 fn source(&self) -> Option<&(dyn Error + 'static)> {
1208 match self {
1209 Self::DataDirectory(source) => Some(source),
1210 Self::Blob(source) => Some(source),
1211 Self::Commit(source) => Some(source),
1212 Self::Execution(source) => Some(source),
1213 Self::Vfs(source) => Some(source),
1214 Self::Store(source) => Some(source),
1215 Self::Approval(source) => Some(source),
1216 Self::CommitPlan(source) => Some(source),
1217 Self::Artifact(source) => Some(source),
1218 Self::ResultCompatibility(source) => Some(source),
1219 Self::RecoveryConflicts(_)
1220 | Self::UnsafeDataDirectory { .. }
1221 | Self::ArtifactBinding { .. }
1222 | Self::MissingPending { .. }
1223 | Self::DuplicatePending { .. }
1224 | Self::EphemeralCapacity { .. }
1225 | Self::PendingPoisoned => None,
1226 }
1227 }
1228}
1229
1230impl From<DataDirectoryError> for VshError {
1231 fn from(source: DataDirectoryError) -> Self {
1232 Self::DataDirectory(source)
1233 }
1234}
1235
1236impl From<BlobStoreError> for VshError {
1237 fn from(source: BlobStoreError) -> Self {
1238 Self::Blob(source)
1239 }
1240}
1241
1242impl From<CommitError> for VshError {
1243 fn from(source: CommitError) -> Self {
1244 Self::Commit(source)
1245 }
1246}
1247
1248impl From<ExecutionError> for VshError {
1249 fn from(source: ExecutionError) -> Self {
1250 Self::Execution(source)
1251 }
1252}
1253
1254impl From<VfsError> for VshError {
1255 fn from(source: VfsError) -> Self {
1256 Self::Vfs(source)
1257 }
1258}
1259
1260impl From<TransactionStoreError> for VshError {
1261 fn from(source: TransactionStoreError) -> Self {
1262 Self::Store(source)
1263 }
1264}
1265
1266impl From<ApprovalGrantError> for VshError {
1267 fn from(source: ApprovalGrantError) -> Self {
1268 Self::Approval(source)
1269 }
1270}
1271
1272impl From<CommitPlanError> for VshError {
1273 fn from(source: CommitPlanError) -> Self {
1274 Self::CommitPlan(source)
1275 }
1276}
1277
1278impl From<ArtifactError> for VshError {
1279 fn from(source: ArtifactError) -> Self {
1280 Self::Artifact(source)
1281 }
1282}
1283
1284impl From<ResultCompatibilityError> for VshError {
1285 fn from(source: ResultCompatibilityError) -> Self {
1286 Self::ResultCompatibility(source)
1287 }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use std::error::Error;
1293 use std::fs;
1294 use std::path::{Path, PathBuf};
1295 use std::sync::atomic::{AtomicU64, Ordering};
1296
1297 use vsh_commit::CommitError;
1298 use vsh_policy::{DenyReason, PolicyProfile};
1299 use vsh_types::{PrincipalId, TransactionState};
1300
1301 use super::{
1302 ApprovalGrantError, ArtifactError, ArtifactLimits, BlobStoreError, CommitPlanError,
1303 DataDirectory, ExecutionBudget, ExecutionError, ReceiptDetail, ResultCompatibility,
1304 ResultCompatibilityError, RunMode, RunRequest, Runtime, RuntimeConfig, RuntimeDecision,
1305 SnapshotLimits, TransactionStoreError, VfsError, VshError,
1306 };
1307
1308 static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
1309
1310 struct TestDirectory(PathBuf);
1311
1312 impl TestDirectory {
1313 fn new(name: &str) -> Self {
1314 let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1315 let path = std::env::temp_dir().join(format!(
1316 "vsh-runtime-{name}-{}-{sequence}",
1317 std::process::id()
1318 ));
1319 fs::create_dir(&path).expect("unique test workspace should be created");
1320 Self(path)
1321 }
1322
1323 fn path(&self) -> &Path {
1324 &self.0
1325 }
1326 }
1327
1328 impl Drop for TestDirectory {
1329 fn drop(&mut self) {
1330 let _ = fs::remove_dir_all(&self.0);
1331 }
1332 }
1333
1334 #[test]
1335 fn auto_mode_commits_one_exact_virtual_result() {
1336 let directory = TestDirectory::new("auto");
1337 fs::write(directory.path().join("input.txt"), b"hello\n").unwrap();
1338 let runtime =
1339 Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
1340 .unwrap();
1341 let receipt = runtime
1342 .run(
1343 RunRequest::new(
1344 r"
1345from pathlib import Path
1346value = Path('/workspace/input.txt').read_text()
1347Path('/workspace/output.txt').write_text(value.upper())
1348len(value)
1349",
1350 )
1351 .with_mode(RunMode::Auto)
1352 .with_detail(ReceiptDetail::Full),
1353 )
1354 .unwrap();
1355
1356 assert_eq!(receipt.state, TransactionState::Committed);
1357 assert!(matches!(receipt.decision, RuntimeDecision::AutoApproved));
1358 assert_eq!(receipt.changed_paths, 1);
1359 assert_eq!(receipt.changes.len(), 1);
1360 assert_eq!(
1361 fs::read(directory.path().join("output.txt")).unwrap(),
1362 b"HELLO\n"
1363 );
1364 assert!(receipt.commit.is_some());
1365 }
1366
1367 #[test]
1368 fn oversized_program_is_rejected_before_workspace_snapshot() {
1369 let directory = TestDirectory::new("program-preflight");
1370 let runtime = Runtime::open(
1371 RuntimeConfig::new(directory.path())
1372 .with_snapshot_limits(SnapshotLimits {
1373 max_nodes: 0,
1374 ..SnapshotLimits::default()
1375 })
1376 .with_in_process_execution(),
1377 )
1378 .unwrap();
1379 let budget = ExecutionBudget {
1380 max_program_bytes: 1,
1381 ..ExecutionBudget::default()
1382 };
1383
1384 let error = runtime
1385 .run(RunRequest::new("42").with_budget(budget))
1386 .unwrap_err();
1387 assert!(matches!(
1388 error,
1389 VshError::Execution(ExecutionError::Limit(source))
1390 if matches!(*source, vsh_monty::ExecutionLimitExceeded::ProgramBytes {
1391 limit: 1,
1392 attempted: 2,
1393 })
1394 ));
1395 }
1396
1397 #[test]
1398 fn process_local_preview_cache_is_bounded_and_explicitly_releasable() {
1399 let directory = TestDirectory::new("ephemeral-capacity");
1400 let runtime = Runtime::open(
1401 RuntimeConfig::new(directory.path())
1402 .with_artifact_limits(ArtifactLimits {
1403 max_ephemeral_entries: 1,
1404 ..ArtifactLimits::default()
1405 })
1406 .with_in_process_execution(),
1407 )
1408 .unwrap();
1409
1410 let first = runtime.preview(RunRequest::new("None")).unwrap();
1411 let error = runtime.preview(RunRequest::new("0")).unwrap_err();
1412 assert!(matches!(
1413 error,
1414 VshError::EphemeralCapacity {
1415 entries: 1,
1416 max_entries: 1,
1417 ..
1418 }
1419 ));
1420
1421 assert!(runtime.discard_preview(first.transaction).unwrap());
1422 assert!(!runtime.discard_preview(first.transaction).unwrap());
1423 runtime.preview(RunRequest::new("1")).unwrap();
1424 }
1425
1426 #[test]
1427 fn python_result_incompatibility_prevents_auto_commit() {
1428 let directory = TestDirectory::new("python-result");
1429 let runtime = Runtime::open(
1430 RuntimeConfig::new(directory.path())
1431 .with_result_compatibility(ResultCompatibility::Python)
1432 .with_in_process_execution(),
1433 )
1434 .unwrap();
1435
1436 let error = runtime
1437 .run(
1438 RunRequest::new(
1439 r"
1440from pathlib import Path
1441Path('/workspace/must-not-exist.txt').write_text('blocked')
1442type({}.keys())
1443",
1444 )
1445 .with_mode(RunMode::Auto),
1446 )
1447 .unwrap_err();
1448
1449 assert!(matches!(error, VshError::ResultCompatibility(_)));
1450 assert!(!directory.path().join("must-not-exist.txt").exists());
1451 }
1452
1453 #[test]
1454 fn strict_preview_requires_exact_approval_before_commit() {
1455 let directory = TestDirectory::new("approval");
1456 let runtime = Runtime::open(
1457 RuntimeConfig::new(directory.path())
1458 .with_policy_profile(PolicyProfile::Strict)
1459 .with_in_process_execution(),
1460 )
1461 .unwrap();
1462 let receipt = runtime
1463 .preview(RunRequest::new(
1464 "from pathlib import Path\nPath('/workspace/approved.txt').write_text('yes')",
1465 ))
1466 .unwrap();
1467 assert_eq!(receipt.state, TransactionState::PendingApproval);
1468 assert!(matches!(
1469 receipt.decision,
1470 RuntimeDecision::PendingApproval(_)
1471 ));
1472 assert!(!directory.path().join("approved.txt").exists());
1473
1474 runtime
1475 .approve(
1476 receipt.transaction,
1477 PrincipalId::digest_label("independent-test-principal"),
1478 10,
1479 20,
1480 )
1481 .unwrap();
1482 let committed = runtime.commit(receipt.transaction, 11).unwrap();
1483 assert_eq!(committed.state, TransactionState::Committed);
1484 assert_eq!(
1485 fs::read(directory.path().join("approved.txt")).unwrap(),
1486 b"yes"
1487 );
1488 }
1489
1490 #[test]
1491 fn approval_artifact_survives_runtime_restart() {
1492 let directory = TestDirectory::new("approval-restart");
1493 let config = RuntimeConfig::new(directory.path())
1494 .with_policy_profile(PolicyProfile::Strict)
1495 .with_in_process_execution();
1496 let receipt = Runtime::open(config.clone())
1497 .unwrap()
1498 .preview(
1499 RunRequest::new(
1500 "from pathlib import Path\nPath('/workspace/restarted.txt').write_text('yes')\n{'answer': 42}",
1501 )
1502 .with_detail(ReceiptDetail::Full),
1503 )
1504 .unwrap();
1505 assert_eq!(receipt.state, TransactionState::PendingApproval);
1506
1507 let reopened = Runtime::open(config).unwrap();
1508 reopened
1509 .approve(
1510 receipt.transaction,
1511 PrincipalId::digest_label("restart-principal"),
1512 100,
1513 200,
1514 )
1515 .unwrap();
1516 let committed = reopened.commit(receipt.transaction, 101).unwrap();
1517
1518 assert_eq!(committed.state, TransactionState::Committed);
1519 assert_eq!(committed.value.py_repr(), "{'answer': 42}");
1520 assert_eq!(committed.changes, receipt.changes);
1521 assert_eq!(
1522 fs::read(directory.path().join("restarted.txt")).unwrap(),
1523 b"yes"
1524 );
1525 }
1526
1527 #[test]
1528 fn caught_protected_read_deterministically_denies_all_changes() {
1529 let directory = TestDirectory::new("deny");
1530 fs::write(directory.path().join(".env"), b"TOKEN=secret\n").unwrap();
1531 let runtime =
1532 Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
1533 .unwrap();
1534 let receipt = runtime
1535 .run(
1536 RunRequest::new(
1537 r"
1538from pathlib import Path
1539try:
1540 Path('/workspace/.env').read_text()
1541except PermissionError:
1542 Path('/workspace/should-not-exist.txt').write_text('blocked')
1543",
1544 )
1545 .with_mode(RunMode::Auto),
1546 )
1547 .unwrap();
1548
1549 assert_eq!(receipt.state, TransactionState::Denied);
1550 assert!(matches!(
1551 receipt.decision,
1552 RuntimeDecision::Denied(ref manifest)
1553 if matches!(manifest.reason, DenyReason::ProtectedAccessAttempt(_))
1554 ));
1555 assert!(!directory.path().join("should-not-exist.txt").exists());
1556 }
1557
1558 #[test]
1559 fn stale_preview_never_overwrites_external_work() {
1560 let directory = TestDirectory::new("stale");
1561 fs::write(directory.path().join("input.txt"), b"before").unwrap();
1562 let runtime =
1563 Runtime::open(RuntimeConfig::new(directory.path()).with_in_process_execution())
1564 .unwrap();
1565 let receipt = runtime
1566 .preview(RunRequest::new(
1567 r"
1568from pathlib import Path
1569value = Path('/workspace/input.txt').read_text()
1570Path('/workspace/output.txt').write_text(value)
1571",
1572 ))
1573 .unwrap();
1574 fs::write(directory.path().join("input.txt"), b"external").unwrap();
1575
1576 let error = runtime.commit(receipt.transaction, 0).unwrap_err();
1577 assert!(matches!(error, VshError::Commit(CommitError::Stale { .. })));
1578 assert!(!directory.path().join("output.txt").exists());
1579 assert_eq!(
1580 runtime.transaction(receipt.transaction).unwrap().state(),
1581 TransactionState::Stale
1582 );
1583 }
1584
1585 #[test]
1586 fn explicit_data_directory_is_external_and_capability_rooted() {
1587 let workspace = TestDirectory::new("external-data-workspace");
1588 let data = TestDirectory::new("external-data-store");
1589 let config = RuntimeConfig::new(workspace.path())
1590 .with_data_directory(data.path())
1591 .with_in_process_execution();
1592
1593 assert_eq!(config.workspace_root(), workspace.path());
1594 assert_eq!(config.data_directory(), data.path());
1595 assert!(config.worker_path().is_none());
1596
1597 let runtime = Runtime::open(config).unwrap();
1598 runtime.preview(RunRequest::new("42")).unwrap();
1599
1600 assert!(data.path().join("blobs").is_dir());
1601 assert!(data.path().join("transactions.lock").is_file());
1602 assert!(workspace.path().join(".vsh-runtime/transactions").is_dir());
1603 }
1604
1605 #[test]
1606 fn explicit_data_directory_cannot_overlap_workspace() {
1607 let workspace = TestDirectory::new("overlapping-data");
1608 let data = workspace.path().join("caller-selected-data");
1609 let result = Runtime::open(
1610 RuntimeConfig::new(workspace.path())
1611 .with_data_directory(&data)
1612 .with_in_process_execution(),
1613 );
1614
1615 assert!(matches!(result, Err(VshError::UnsafeDataDirectory { .. })));
1616 assert!(!data.exists());
1617 }
1618
1619 #[cfg(unix)]
1620 #[test]
1621 fn default_runtime_symlink_fails_before_external_write() {
1622 use std::os::unix::fs::symlink;
1623
1624 let workspace = TestDirectory::new("runtime-symlink-workspace");
1625 let outside = TestDirectory::new("runtime-symlink-outside");
1626 symlink(outside.path(), workspace.path().join(".vsh-runtime")).unwrap();
1627
1628 let result =
1629 Runtime::open(RuntimeConfig::new(workspace.path()).with_in_process_execution());
1630
1631 assert!(matches!(
1632 result,
1633 Err(VshError::Commit(CommitError::InternalIo { .. }))
1634 ));
1635 assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
1636 }
1637
1638 #[cfg(unix)]
1639 #[test]
1640 fn canonical_alias_into_workspace_is_rejected_before_store_files() {
1641 use std::os::unix::fs::symlink;
1642
1643 let workspace = TestDirectory::new("canonical-overlap-workspace");
1644 let alias_root = TestDirectory::new("canonical-overlap-alias");
1645 let alias = alias_root.path().join("workspace-alias");
1646 symlink(workspace.path(), &alias).unwrap();
1647 let data = alias.join("nested-data");
1648
1649 let result = Runtime::open(
1650 RuntimeConfig::new(workspace.path())
1651 .with_data_directory(&data)
1652 .with_in_process_execution(),
1653 );
1654
1655 assert!(matches!(result, Err(VshError::UnsafeDataDirectory { .. })));
1656 assert!(!workspace.path().join("nested-data").exists());
1657 }
1658
1659 #[test]
1660 fn native_error_surface_is_catchable_and_stable() {
1661 let directory = TestDirectory::new("runtime-errors");
1662 let not_a_directory = directory.path().join("file");
1663 fs::write(¬_a_directory, b"file").unwrap();
1664 let data_error = DataDirectory::open_trusted(¬_a_directory).unwrap_err();
1665 let transaction = vsh_types::TransactionId::from_bytes([7; 32]);
1666 let sourced = [
1667 VshError::DataDirectory(data_error),
1668 VshError::Blob(BlobStoreError::Io {
1669 operation: "read",
1670 path: PathBuf::from("blob"),
1671 source: std::io::Error::other("test"),
1672 }),
1673 VshError::Commit(CommitError::BaseSnapshotBinding),
1674 VshError::Execution(ExecutionError::UnsupportedSuspension {
1675 kind: "test",
1676 name: Some("name".to_owned()),
1677 }),
1678 VshError::Vfs(VfsError::RootMutation),
1679 VshError::Store(TransactionStoreError::NotFound { id: transaction }),
1680 VshError::Approval(ApprovalGrantError::InvalidWindow {
1681 issued_at_unix_ms: 2,
1682 expires_at_unix_ms: 1,
1683 }),
1684 VshError::CommitPlan(CommitPlanError::RootMutation),
1685 VshError::Artifact(ArtifactError::BindingMismatch),
1686 VshError::ResultCompatibility(ResultCompatibilityError::Depth {
1687 limit: 1,
1688 attempted: 2,
1689 }),
1690 ];
1691 for error in sourced {
1692 assert!(!error.to_string().is_empty());
1693 assert!(Error::source(&error).is_some());
1694 }
1695
1696 let unsourced = [
1697 VshError::UnsafeDataDirectory {
1698 workspace_root: PathBuf::from("workspace"),
1699 data_directory: PathBuf::from("workspace/data"),
1700 },
1701 VshError::ArtifactBinding {
1702 requested: transaction,
1703 decoded: vsh_types::TransactionId::from_bytes([8; 32]),
1704 },
1705 VshError::RecoveryConflicts(Box::default()),
1706 VshError::MissingPending { transaction },
1707 VshError::DuplicatePending { transaction },
1708 VshError::EphemeralCapacity {
1709 entries: 2,
1710 max_entries: 1,
1711 attempted_bytes: 2,
1712 max_bytes: 1,
1713 },
1714 VshError::PendingPoisoned,
1715 ];
1716 for error in unsourced {
1717 assert!(!error.to_string().is_empty());
1718 assert!(Error::source(&error).is_none());
1719 }
1720 }
1721}