Skip to main content

vsh/
runtime.rs

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
33/// Request-scoped resource caps enforced by the Monty/VFS adapter.
34pub type ExecutionBudget = ExecutionLimits;
35
36/// Hard allocation and cardinality bounds for durable approval artifacts.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct ArtifactLimits {
39    /// Maximum complete encoded artifact bytes.
40    pub max_bytes: usize,
41    /// Maximum postcard-encoded Monty result bytes.
42    pub max_value_bytes: usize,
43    /// Maximum retained UTF-8 stdout bytes.
44    pub max_stdout_bytes: usize,
45    /// Maximum canonical changed paths.
46    pub max_entries: usize,
47    /// Maximum read or write dependency entries.
48    pub max_dependencies: usize,
49    /// Maximum one-path UTF-8 byte length.
50    pub max_path_bytes: usize,
51    /// Maximum process-local auto-approved previews retained by one runtime.
52    pub max_ephemeral_entries: usize,
53    /// Maximum encoded bytes retained by process-local auto-approved previews.
54    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/// Whether one call stops after policy or commits deterministic auto-approvals.
73#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub enum RunMode {
75    /// Produce an approval-bound virtual result without changing the host workspace.
76    #[default]
77    Preview,
78    /// Commit only when deterministic policy returns `AutoApprove`.
79    Auto,
80}
81
82/// Amount of canonical change detail retained in a receipt.
83#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
84pub enum ReceiptDetail {
85    /// Retain bounded counts and digests, but no per-path diff entries.
86    #[default]
87    Compact,
88    /// Retain the complete bounded canonical diff.
89    Full,
90}
91
92/// One borrowed native execution request.
93#[derive(Clone, Copy, Debug)]
94pub struct RunRequest<'a> {
95    /// Exact Monty source executed against virtual state.
96    pub code: &'a str,
97    /// Optional out-of-band intent bound into transaction identity.
98    pub intent: Option<&'a str>,
99    /// Preview-only or deterministic auto-commit behavior.
100    pub mode: RunMode,
101    /// Compact or complete canonical change detail.
102    pub detail: ReceiptDetail,
103    /// Independent execution caps for this request.
104    pub budget: ExecutionBudget,
105}
106
107impl<'a> RunRequest<'a> {
108    /// Construct a safe preview request with default resource caps.
109    #[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    /// Bind an out-of-band intent to this request.
121    #[must_use]
122    pub const fn with_intent(mut self, intent: &'a str) -> Self {
123        self.intent = Some(intent);
124        self
125    }
126
127    /// Select preview or deterministic auto-commit behavior.
128    #[must_use]
129    pub const fn with_mode(mut self, mode: RunMode) -> Self {
130        self.mode = mode;
131        self
132    }
133
134    /// Select compact or complete receipt detail.
135    #[must_use]
136    pub const fn with_detail(mut self, detail: ReceiptDetail) -> Self {
137        self.detail = detail;
138        self
139    }
140
141    /// Replace all request-scoped execution caps.
142    #[must_use]
143    pub const fn with_budget(mut self, budget: ExecutionBudget) -> Self {
144        self.budget = budget;
145        self
146    }
147}
148
149/// Deterministic policy result retained in the native receipt.
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum RuntimeDecision {
152    /// Deterministic policy rejected the transaction.
153    Denied(DenyManifest),
154    /// Deterministic policy authorized reservation without a judge.
155    AutoApproved,
156    /// An exact independent approval is required before reservation.
157    PendingApproval(RiskManifest),
158}
159
160/// Monotonic stage costs recorded without string allocation in the hot path.
161#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162pub struct StageTimings {
163    /// Capability-rooted metadata snapshot time.
164    pub snapshot_ns: u64,
165    /// Monty execution plus typed VFS-call time.
166    pub execute_ns: u64,
167    /// Canonical-diff freeze time.
168    pub diff_ns: u64,
169    /// Deterministic-policy evaluation time.
170    pub policy_ns: u64,
171    /// Artifact binding and short state-store transitions.
172    pub bind_and_store_ns: u64,
173    /// Reservation, dependency revalidation, commit, and verification time.
174    pub commit_ns: u64,
175    /// Complete native call time.
176    pub total_ns: u64,
177}
178
179/// Compact proof of virtual execution, policy, and optional verified commit.
180#[derive(Clone, Debug)]
181pub struct Receipt {
182    /// Approval- and commit-bound transaction identity.
183    pub transaction: TransactionId,
184    /// Immutable base snapshot identity.
185    pub base_snapshot: SnapshotId,
186    /// Current lifecycle state. Auto-approved previews may be process-local until commit.
187    pub state: TransactionState,
188    /// Deterministic policy result.
189    pub decision: RuntimeDecision,
190    /// Canonical diff identity.
191    pub diff: DiffDigest,
192    /// Number of canonical changed paths.
193    pub changed_paths: usize,
194    /// Complete canonical entries only when full detail was requested.
195    pub changes: Vec<DiffEntry>,
196    /// Bounded Monty return value.
197    pub value: MontyObject,
198    /// Bounded captured `print()` output.
199    pub stdout: String,
200    /// Independent execution counters.
201    pub execution: ExecutionStats,
202    /// Native stage timings.
203    pub timings: StageTimings,
204    /// Durable commit proof when the host was changed and verified.
205    pub commit: Option<CommitReceipt>,
206}
207
208/// Immutable runtime configuration shared by Rust and `PyO3` callers.
209#[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    /// Construct a balanced runtime rooted at `workspace_root`.
233    ///
234    /// Durable internal artifacts default to `.vsh-runtime/data` below the workspace;
235    /// that namespace is excluded from snapshots and denied to Monty.
236    #[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    /// Place immutable blobs in an explicit trusted data directory.
257    #[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    /// Select the exact supervised Monty worker executable used for hostile code.
265    #[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    /// Bound clean workers retained for low-latency reuse. Zero disables pooling.
272    #[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    /// Require every result to be faithfully representable by one host surface.
279    #[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    /// Disable crash isolation for trusted embedding and deterministic test harnesses.
289    ///
290    /// This mode must never execute hostile or unreviewed code. Production Rust and
291    /// Python callers use the supervised worker by default.
292    #[must_use]
293    pub fn with_in_process_execution(mut self) -> Self {
294        self.worker_path = None;
295        self
296    }
297
298    /// Replace the synthetic absolute namespace exposed to Monty.
299    #[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    /// Replace deterministic transaction and pre-call policy.
306    #[must_use]
307    pub fn with_policy(mut self, policy: TransactionPolicy) -> Self {
308        self.policy = policy;
309        self
310    }
311
312    /// Select a built-in deterministic policy profile.
313    #[must_use]
314    pub fn with_policy_profile(self, profile: PolicyProfile) -> Self {
315        self.with_policy(TransactionPolicy::preset(profile))
316    }
317
318    /// Replace eager snapshot traversal bounds.
319    #[must_use]
320    pub const fn with_snapshot_limits(mut self, limits: SnapshotLimits) -> Self {
321        self.snapshot_limits = limits;
322        self
323    }
324
325    /// Replace trusted commit and recovery bounds.
326    #[must_use]
327    pub const fn with_commit_config(mut self, config: CommitConfig) -> Self {
328        self.commit_config = config;
329        self
330    }
331
332    /// Replace durable transaction-log bounds.
333    #[must_use]
334    pub const fn with_store_config(mut self, config: FileStoreConfig) -> Self {
335        self.store_config = config;
336        self
337    }
338
339    /// Replace durable pending-artifact allocation and cardinality bounds.
340    #[must_use]
341    pub const fn with_artifact_limits(mut self, limits: ArtifactLimits) -> Self {
342        self.artifact_limits = limits;
343        self
344    }
345
346    /// Return the host workspace authority root.
347    #[must_use]
348    pub fn workspace_root(&self) -> &Path {
349        &self.workspace_root
350    }
351
352    /// Return the trusted immutable-artifact directory.
353    #[must_use]
354    pub fn data_directory(&self) -> &Path {
355        &self.data_directory
356    }
357
358    /// Return the supervised worker path, or `None` for explicit trusted in-process mode.
359    #[must_use]
360    pub fn worker_path(&self) -> Option<&Path> {
361        self.worker_path.as_deref()
362    }
363
364    /// Return deterministic transaction policy.
365    #[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
416/// One native VSH engine instance with no process-global execution lock.
417pub 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    /// Open one capability-rooted runtime and recover durable interrupted commits.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error when blob storage, workspace capability setup, recovery, or
439    /// fail-closed recovery conflict handling fails.
440    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    /// Return the startup recovery work completed before accepting requests.
477    #[must_use]
478    pub const fn startup_recovery(&self) -> &RecoveryReport {
479        &self.startup_recovery
480    }
481
482    /// Execute, evaluate, and optionally auto-commit one exact transaction.
483    ///
484    /// # Errors
485    ///
486    /// Returns a typed error for snapshot, execution, diff, state, binding, reservation,
487    /// revalidation, commit, or recovery failures. Deterministic policy denial is a
488    /// successful receipt and never reaches the committer.
489    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    /// Force preview-only behavior regardless of the request's mode field.
596    ///
597    /// # Errors
598    ///
599    /// Returns the same typed failures as [`Self::run`].
600    pub fn preview(&self, mut request: RunRequest<'_>) -> Result<Receipt, VshError> {
601        request.mode = RunMode::Preview;
602        self.run(request)
603    }
604
605    /// Forget one process-local auto-approved preview without mutating the host.
606    ///
607    /// Durable approval-required artifacts are never removed by this method. `false`
608    /// means this runtime did not retain the supplied preview.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error only when the bounded pending-artifact lock was poisoned.
613    pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
614        self.remove_pending(transaction)
615            .map(|artifact| artifact.is_some())
616    }
617
618    /// Bind an independent, expiring approval to one exact pending transaction.
619    ///
620    /// # Errors
621    ///
622    /// Returns an error for an invalid time window, missing transaction, mismatched
623    /// binding, wrong state, or internal artifact-state mismatch.
624    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    /// Consume the single-use reservation and commit one previewed transaction.
646    ///
647    /// # Errors
648    ///
649    /// Returns an error for missing artifacts, expired approval, replay, stale host
650    /// dependencies, commit/recovery failures, or internal binding mismatch.
651    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    /// Recover all durable commit artifacts under this runtime's capability root.
680    ///
681    /// # Errors
682    ///
683    /// Returns a typed commit/recovery error for corrupt or unsafe journals.
684    pub fn recover(&self) -> Result<RecoveryReport, VshError> {
685        self.committer.recover(&self.store).map_err(Into::into)
686    }
687
688    /// Return one persisted lifecycle record.
689    ///
690    /// # Errors
691    ///
692    /// Returns [`VshError::Store`] when the transaction does not exist.
693    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/// Stable native error surface shared with the Python exception mapper.
1084#[derive(Debug)]
1085#[non_exhaustive]
1086pub enum VshError {
1087    /// The durable data-directory capability could not be established safely.
1088    DataDirectory(DataDirectoryError),
1089    /// Immutable blob storage failed.
1090    Blob(BlobStoreError),
1091    /// Capability-rooted commit or recovery failed.
1092    Commit(CommitError),
1093    /// Monty compilation, execution, or a hard execution budget failed.
1094    Execution(ExecutionError),
1095    /// Virtual filesystem integrity or canonical diff generation failed.
1096    Vfs(VfsError),
1097    /// Atomic transaction-state operation failed.
1098    Store(TransactionStoreError),
1099    /// An approval grant had an invalid time window.
1100    Approval(ApprovalGrantError),
1101    /// The internal commit artifact did not match its binding.
1102    CommitPlan(CommitPlanError),
1103    /// Durable pending-artifact encoding or validation failed.
1104    Artifact(ArtifactError),
1105    /// The selected SDK surface cannot faithfully project the Monty result.
1106    ResultCompatibility(ResultCompatibilityError),
1107    /// A caller-selected data directory overlaps the untrusted workspace.
1108    UnsafeDataDirectory {
1109        /// Host workspace capability root.
1110        workspace_root: PathBuf,
1111        /// Rejected caller-selected durable directory.
1112        data_directory: PathBuf,
1113    },
1114    /// A content-addressed artifact decoded to another transaction identity.
1115    ArtifactBinding {
1116        /// Transaction requested by the caller and state store.
1117        requested: TransactionId,
1118        /// Transaction recomputed from decoded artifact contents.
1119        decoded: TransactionId,
1120    },
1121    /// Startup recovery found ownership it could not prove and left it untouched.
1122    RecoveryConflicts(Box<RecoveryReport>),
1123    /// The transaction record has no durable exact artifact.
1124    MissingPending {
1125        /// Requested transaction.
1126        transaction: TransactionId,
1127    },
1128    /// A duplicate exact artifact attempted to occupy the pending map.
1129    DuplicatePending {
1130        /// Duplicate transaction.
1131        transaction: TransactionId,
1132    },
1133    /// Process-local preview retention reached its configured hard bound.
1134    EphemeralCapacity {
1135        /// Number of previews retained before this attempt.
1136        entries: usize,
1137        /// Maximum previews retained by one runtime.
1138        max_entries: usize,
1139        /// Total encoded bytes that retaining this preview would require.
1140        attempted_bytes: usize,
1141        /// Maximum encoded bytes retained by one runtime.
1142        max_bytes: usize,
1143    },
1144    /// The short-lived pending-artifact mutex was poisoned by a panic.
1145    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(&not_a_directory, b"file").unwrap();
1664        let data_error = DataDirectory::open_trusted(&not_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}