Skip to main content

switchyard_libsy/algorithms/vgr/
checker.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! A checker that runs a task's own tests against a pinned, verified snapshot.
5//!
6//! This is the evidence source the checks regime commits on. It executes an
7//! operator-supplied command and reports the exit code, but the part that makes
8//! its verdict worth trusting is the manifest: the test suite is copied into a
9//! private snapshot at construction, hashed file by file, and re-verified both
10//! before the run and again before any pass is reported.
11//!
12//! # What this owns, and what it does not
13//!
14//! Resource limits, network isolation and filesystem confinement are **not**
15//! implemented here. They belong to the deployment sandbox the router and the
16//! agent both run inside, which can enforce them at the kernel level; a
17//! same-uid child process cannot meaningfully confine itself. The reference
18//! implementation reaches the same conclusion and says so explicitly.
19//!
20//! What no deployment sandbox can provide is the manifest. The attempt runs as
21//! the same user, inside the same sandbox, with the same view of the filesystem
22//! as the tests it is judged by — so nothing but re-hashing stops an attempt
23//! from editing those tests and passing. That is this module's job.
24//!
25//! # Fail-closed
26//!
27//! Only a clean exit with the manifest verified twice reports a pass. A
28//! non-zero exit is a fail. Everything else — a command that could not be
29//! spawned, a snapshot that no longer matches, a run the caller's deadline cut
30//! short — is indeterminate, which commits nothing.
31
32use std::collections::BTreeMap;
33use std::fmt;
34use std::fs::OpenOptions;
35use std::io::{Read, Write};
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use std::time::Duration;
39
40use tempfile::TempDir;
41use tokio::sync::{OwnedSemaphorePermit, Semaphore};
42
43use self::platform::{
44    MutationStamp, ProcessTreeReaper, configure_process_tree, is_directory, is_regular_file,
45    mutation_stamp_file, mutation_stamp_path, open_regular_file, os_str_bytes,
46};
47use super::config::{Checker, CheckerRequest};
48
49mod platform;
50
51/// Characters of the attempt and task written into private control files.
52///
53/// The command reads them from files rather than argv, so a large attempt
54/// cannot overflow the argument list.
55const ATTEMPT_FILE: &str = "attempt.txt";
56const TASK_FILE: &str = "task.txt";
57const WORKSPACE_ENV: &str = "WORKSPACE_DIR";
58const HASH_CHUNK_BYTES: usize = 64 * 1024;
59const DEFAULT_MAX_ENTRIES: usize = 100_000;
60const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
61#[cfg(unix)]
62const PROTECTED_ENV: [&str; 8] = [
63    "TESTS_DIR",
64    "ATTEMPT_FILE",
65    "TASK_FILE",
66    "HOME",
67    "TMPDIR",
68    "PATH",
69    "LANG",
70    WORKSPACE_ENV,
71];
72#[cfg(windows)]
73const WINDOWS_HOST_ENV: [&str; 5] = ["SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "PSMODULEPATH"];
74#[cfg(windows)]
75const PROTECTED_ENV: [&str; 16] = [
76    "TESTS_DIR",
77    "ATTEMPT_FILE",
78    "TASK_FILE",
79    "HOME",
80    "TMPDIR",
81    "PATH",
82    "LANG",
83    WORKSPACE_ENV,
84    "TEMP",
85    "TMP",
86    "USERPROFILE",
87    "SYSTEMROOT",
88    "WINDIR",
89    "COMSPEC",
90    "PATHEXT",
91    "PSMODULEPATH",
92];
93
94/// The operator's attestation that a deployment sandbox confines this checker.
95///
96/// Required verbatim, because everything this module does not enforce — memory,
97/// CPU, network, filesystem reach — is only enforced if something else is doing
98/// it. Making that an explicit claim keeps a checker from being configured on a
99/// bare host under the impression it is contained.
100pub const SANDBOX_ATTESTATION: &str = "vgr-checker-runs-in-deployment-sandbox";
101
102/// An RAII-owned candidate source tree materialized for one checker run.
103pub struct CandidateWorkspace {
104    root: TempDir,
105}
106
107impl CandidateWorkspace {
108    /// Owns a newly materialized workspace until the checker run finishes.
109    pub fn new(root: TempDir) -> std::io::Result<Self> {
110        let metadata = std::fs::symlink_metadata(root.path())?;
111        if !is_directory(&metadata) {
112            return Err(std::io::Error::new(
113                std::io::ErrorKind::InvalidInput,
114                "candidate workspace root is not a directory",
115            ));
116        }
117        Ok(Self { root })
118    }
119
120    /// The candidate source directory used as the checker's working directory.
121    pub fn path(&self) -> &Path {
122        self.root.path()
123    }
124}
125
126/// Trusted host materialization for the candidate source tree under test.
127///
128/// Implementations may interpret the candidate text, but the checker does not.
129/// The identity must be stable for equivalent materialization behavior and must
130/// not contain request data; it is included in the public manifest digest.
131#[async_trait::async_trait]
132pub trait WorkspaceProvider: Send + Sync {
133    /// Stable, non-sensitive identity of this materialization contract.
134    fn manifest_identity(&self) -> &str;
135
136    /// Materializes one candidate source tree and returns its owned lifetime.
137    async fn materialize(
138        &self,
139        task_text: &str,
140        attempt: &str,
141    ) -> std::io::Result<CandidateWorkspace>;
142}
143
144/// Fixed operator configuration for [`CommandWorkspaceProvider`].
145#[derive(Clone)]
146pub struct CommandWorkspaceProviderConfig {
147    /// Trusted operator command that populates the candidate workspace.
148    pub command: Vec<String>,
149    /// Maximum time allowed for one materialization command.
150    pub timeout: Duration,
151    /// Extra environment entries applied before protected host values.
152    pub env: Vec<(String, String)>,
153}
154
155impl CommandWorkspaceProviderConfig {
156    /// Configures a materializer argv and its execution timeout.
157    pub fn new(command: Vec<String>, timeout: Duration) -> Self {
158        Self {
159            command,
160            timeout,
161            env: Vec::new(),
162        }
163    }
164}
165
166impl fmt::Debug for CommandWorkspaceProviderConfig {
167    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
168        let env_keys = self
169            .env
170            .iter()
171            .map(|(key, _value)| key.as_str())
172            .collect::<Vec<_>>();
173        formatter
174            .debug_struct("CommandWorkspaceProviderConfig")
175            .field("command", &self.command)
176            .field("timeout", &self.timeout)
177            .field("env_keys", &env_keys)
178            .finish()
179    }
180}
181
182/// Why a command-backed workspace provider could not be built.
183#[derive(Debug, thiserror::Error)]
184pub enum CommandWorkspaceProviderSetupError {
185    /// The materializer command was empty.
186    #[error("candidate materialize command must be a non-empty argv list")]
187    EmptyCommand,
188    /// A protected environment value was supplied by the operator.
189    #[error("candidate materializer environment key {0:?} is reserved for the host")]
190    ReservedEnvironmentKey(String),
191    /// An environment key or value cannot be passed to a process.
192    #[error("candidate materializer environment entry {0:?} is invalid")]
193    InvalidEnvironmentEntry(String),
194}
195
196/// Materializes each candidate with a trusted operator command.
197///
198/// Request content reaches the command only through private control files. The
199/// command receives no inherited environment and never receives the pinned test
200/// directory. Its stable identity covers only fixed operator configuration.
201pub struct CommandWorkspaceProvider {
202    config: CommandWorkspaceProviderConfig,
203    manifest_identity: String,
204    protected_path: String,
205}
206
207impl CommandWorkspaceProvider {
208    /// Validates and builds a command-backed materialization contract.
209    pub fn new(
210        config: CommandWorkspaceProviderConfig,
211    ) -> Result<Self, CommandWorkspaceProviderSetupError> {
212        validate_materializer_config(&config)?;
213        let protected_path = current_path();
214        let manifest_identity = materializer_manifest_identity(&config, &protected_path);
215        Ok(Self {
216            config,
217            manifest_identity,
218            protected_path,
219        })
220    }
221
222    /// Runs one materializer in a fresh, owned candidate workspace.
223    async fn materialize_once(
224        &self,
225        task_text: &str,
226        attempt: &str,
227    ) -> std::io::Result<CandidateWorkspace> {
228        let workspace = TempDir::with_prefix("vgr-candidate-")?;
229        let control = TempDir::with_prefix("vgr-materializer-control-")?;
230        let tmp = control.path().join("tmp");
231        tokio::fs::create_dir(&tmp).await?;
232        let attempt_file = control.path().join(ATTEMPT_FILE);
233        let task_file = control.path().join(TASK_FILE);
234        tokio::fs::write(&attempt_file, attempt).await?;
235        tokio::fs::write(&task_file, task_text).await?;
236
237        let (program, arguments) = self
238            .config
239            .command
240            .split_first()
241            .ok_or_else(|| std::io::Error::other("candidate materialize command is empty"))?;
242        let mut command = tokio::process::Command::new(program);
243        command
244            .args(arguments)
245            .current_dir(workspace.path())
246            .env_clear();
247        for (key, value) in &self.config.env {
248            command.env(key, value);
249        }
250        // These values are intentionally last so only the host can select the
251        // control files and candidate workspace. The pinned tests stay absent.
252        command
253            .env("PATH", &self.protected_path)
254            .env("HOME", workspace.path())
255            .env("TMPDIR", &tmp)
256            .env("LANG", "C.UTF-8")
257            .env("ATTEMPT_FILE", &attempt_file)
258            .env("TASK_FILE", &task_file)
259            .env(WORKSPACE_ENV, workspace.path())
260            .env_remove("TESTS_DIR")
261            .stdin(std::process::Stdio::null())
262            .stdout(std::process::Stdio::null())
263            .stderr(std::process::Stdio::null())
264            .kill_on_drop(true);
265        #[cfg(windows)]
266        command
267            .env("USERPROFILE", workspace.path())
268            .env("TEMP", &tmp)
269            .env("TMP", &tmp);
270        #[cfg(windows)]
271        for (key, value) in windows_host_environment() {
272            command.env(key, value);
273        }
274        configure_process_tree(&mut command);
275
276        let mut child = command.spawn()?;
277        let _reaper = ProcessTreeReaper::attach(&child)?;
278        let status = child.wait().await?;
279        tracing::debug!(
280            target: "libsy",
281            status = status.code(),
282            "vgr candidate materializer run"
283        );
284        if !status.success() {
285            return Err(std::io::Error::other(
286                "candidate materializer exited unsuccessfully",
287            ));
288        }
289        CandidateWorkspace::new(workspace)
290    }
291}
292
293#[async_trait::async_trait]
294impl WorkspaceProvider for CommandWorkspaceProvider {
295    fn manifest_identity(&self) -> &str {
296        &self.manifest_identity
297    }
298
299    async fn materialize(
300        &self,
301        task_text: &str,
302        attempt: &str,
303    ) -> std::io::Result<CandidateWorkspace> {
304        match tokio::time::timeout(
305            self.config.timeout,
306            self.materialize_once(task_text, attempt),
307        )
308        .await
309        {
310            Ok(result) => result,
311            Err(_elapsed) => Err(std::io::Error::new(
312                std::io::ErrorKind::TimedOut,
313                "candidate materializer timed out",
314            )),
315        }
316    }
317}
318
319/// How an operator configures the checker.
320#[derive(Clone)]
321pub struct CheckerConfig {
322    /// Directory holding the task's test suite. Copied at construction.
323    pub tests_dir: PathBuf,
324    /// The command to run, as an argv list. Never a shell string.
325    ///
326    /// Two placeholders are substituted per run: `{tests}` becomes the private
327    /// per-run test copy, and `{workdir}` becomes the candidate workspace.
328    pub command: Vec<String>,
329    /// How long materialization, verification, and command execution may take.
330    pub timeout: Duration,
331    /// Extra environment entries applied before the protected host values.
332    pub env: Vec<(String, String)>,
333    /// Maximum namespace entries, including the test tree root.
334    pub max_snapshot_entries: usize,
335    /// Maximum regular-file bytes read during each copy or verification pass.
336    pub max_snapshot_bytes: u64,
337    /// Trusted host materialization for the candidate sources under test.
338    pub workspace_provider: Arc<dyn WorkspaceProvider>,
339    /// The operator's [`SANDBOX_ATTESTATION`], recorded verbatim.
340    pub sandbox_attestation: String,
341}
342
343impl CheckerConfig {
344    /// A configuration running `command` against the suite in `tests_dir`.
345    pub fn new(
346        tests_dir: impl Into<PathBuf>,
347        command: Vec<String>,
348        workspace_provider: Arc<dyn WorkspaceProvider>,
349    ) -> Self {
350        Self {
351            tests_dir: tests_dir.into(),
352            command,
353            timeout: Duration::from_secs(120),
354            env: Vec::new(),
355            max_snapshot_entries: DEFAULT_MAX_ENTRIES,
356            max_snapshot_bytes: DEFAULT_MAX_BYTES,
357            workspace_provider,
358            sandbox_attestation: String::new(),
359        }
360    }
361}
362
363impl fmt::Debug for CheckerConfig {
364    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365        let env_keys = self
366            .env
367            .iter()
368            .map(|(key, _value)| key.as_str())
369            .collect::<Vec<_>>();
370        formatter
371            .debug_struct("CheckerConfig")
372            .field("tests_dir", &self.tests_dir)
373            .field("command", &self.command)
374            .field("timeout", &self.timeout)
375            .field("env_keys", &env_keys)
376            .field("max_snapshot_entries", &self.max_snapshot_entries)
377            .field("max_snapshot_bytes", &self.max_snapshot_bytes)
378            .field(
379                "workspace_provider",
380                &self.workspace_provider.manifest_identity(),
381            )
382            .field("sandbox_attestation", &self.sandbox_attestation)
383            .finish()
384    }
385}
386
387/// Why a checker could not be built.
388#[derive(Debug, thiserror::Error)]
389pub enum CheckerSetupError {
390    /// The command was empty, so there is nothing to run.
391    #[error("checker command must be a non-empty argv list")]
392    EmptyCommand,
393    /// The operator did not attest that a deployment sandbox confines this checker.
394    #[error("checker requires the sandbox attestation {SANDBOX_ATTESTATION:?}")]
395    NotAttested,
396    /// A protected environment value was supplied by the operator.
397    #[error("checker environment key {0:?} is reserved for the host")]
398    ReservedEnvironmentKey(String),
399    /// An environment key or value cannot be passed to a process.
400    #[error("checker environment entry {0:?} is invalid")]
401    InvalidEnvironmentEntry(String),
402    /// The materialization contract has no safe stable identity.
403    #[error("checker workspace provider requires a stable non-empty manifest identity")]
404    InvalidWorkspaceIdentity,
405    /// Snapshot resource limits must both be non-zero.
406    #[error("checker snapshot byte and entry limits must be non-zero")]
407    InvalidSnapshotLimits,
408    /// The test suite could not be snapshotted.
409    #[error("checker could not snapshot its test suite: {0}")]
410    Snapshot(#[source] std::io::Error),
411    /// The suite holds something the manifest cannot represent.
412    #[error(
413        "checker test suite entry {0:?} is neither a regular file nor a directory, \
414         so it cannot be snapshotted or hashed"
415    )]
416    UnsupportedEntry(PathBuf),
417    /// The suite contains more namespace entries than the configured bound.
418    #[error("checker test suite exceeds the {0}-entry snapshot limit")]
419    SnapshotEntryLimit(usize),
420    /// The suite contains more file bytes than the configured bound.
421    #[error("checker test suite exceeds the {0}-byte snapshot limit")]
422    SnapshotByteLimit(u64),
423}
424
425/// The pinned record of what the checker will run, and against what.
426///
427/// Its stable `sha` covers paths, entry kinds, file contents, command,
428/// materialization contract, limits, and effective environment. Volatile
429/// inode and ctime stamps are kept separately and never enter this identity.
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct Manifest {
432    entries: BTreeMap<PathBuf, StableEntry>,
433    /// One stable public audit hash over the complete checker contract.
434    pub sha: String,
435}
436
437/// Runs a task's tests against a pinned, hash-verified snapshot.
438///
439/// Named for what it actually guarantees. It does not sandbox: confinement is
440/// the deployment sandbox's job, and a type named for a property it does not
441/// enforce would be read as a safety claim it cannot honour.
442pub struct PinnedChecker {
443    config: CheckerConfig,
444    /// The private pinned source. Commands only see a private copy of it.
445    snapshot: TempDir,
446    pinned: TreeSnapshot,
447    manifest: Manifest,
448    admission: Arc<Semaphore>,
449    protected_path: String,
450}
451
452impl PinnedChecker {
453    /// Snapshots the test suite and pins its manifest.
454    ///
455    /// The snapshot is taken once, at construction, so that later edits to the
456    /// operator's original directory cannot change what is being verified
457    /// against mid-flight.
458    pub fn new(config: CheckerConfig) -> Result<Self, CheckerSetupError> {
459        validate_config(&config)?;
460        let limits = SnapshotLimits::from(&config);
461        let snapshot = TempDir::with_prefix("vgr-checker-").map_err(CheckerSetupError::Snapshot)?;
462        let tests_path = snapshot.path().join("tests");
463        copy_tree(&config.tests_dir, &tests_path, limits).map_err(CheckerSetupError::from)?;
464        // Read-only is a courtesy, not the control: the same uid can undo it.
465        // Stable namespace/content plus mutation stamps are the actual control.
466        set_read_only(&tests_path).map_err(CheckerSetupError::from)?;
467        let pinned = snapshot_tree(&tests_path, limits).map_err(CheckerSetupError::from)?;
468        let entries = pinned.entries.clone();
469        let protected_path = current_path();
470        let manifest = Manifest {
471            sha: manifest_sha(&entries, &config, &protected_path),
472            entries,
473        };
474        Ok(Self {
475            config,
476            snapshot,
477            pinned,
478            manifest,
479            admission: Arc::new(Semaphore::new(1)),
480            protected_path,
481        })
482    }
483
484    /// The pinned manifest this checker verifies against.
485    pub fn manifest(&self) -> &Manifest {
486        &self.manifest
487    }
488
489    /// The snapshot the tests were pinned into.
490    fn tests_path(&self) -> PathBuf {
491        self.snapshot.path().join("tests")
492    }
493
494    /// Stable public identity of the pinned checker contract.
495    pub fn manifest_identity(&self) -> &str {
496        &self.manifest.sha
497    }
498
499    /// Runs the checker through its public request contract in unit tests.
500    #[cfg(test)]
501    async fn check(&self, task_text: &str, attempt: &str) -> Option<bool> {
502        let deadline = std::time::Instant::now() + self.config.timeout;
503        <Self as Checker>::check(
504            self,
505            CheckerRequest {
506                task_text,
507                attempt,
508                deadline,
509                remaining: self.config.timeout,
510                manifest_identity: self.manifest_identity(),
511            },
512        )
513        .await
514    }
515
516    /// Runs one admitted checker operation from materialization through teardown.
517    async fn check_once(&self, task_text: &str, attempt: &str) -> Option<bool> {
518        let permit = match Arc::clone(&self.admission).acquire_owned().await {
519            Ok(permit) => permit,
520            Err(_closed) => return None,
521        };
522        let workspace = match self
523            .config
524            .workspace_provider
525            .materialize(task_text, attempt)
526            .await
527        {
528            Ok(workspace) => workspace,
529            Err(error) => {
530                tracing::warn!(
531                    target: "libsy",
532                    kind = ?error.kind(),
533                    "vgr checker could not materialize its candidate workspace"
534                );
535                return None;
536            }
537        };
538
539        let control = match TempDir::with_prefix("vgr-checker-control-") {
540            Ok(control) => control,
541            Err(error) => {
542                tracing::warn!(
543                    target: "libsy",
544                    kind = ?error.kind(),
545                    "vgr checker could not create its private control directory"
546                );
547                return None;
548            }
549        };
550        let tmp = control.path().join("tmp");
551        if let Err(error) = tokio::fs::create_dir(&tmp).await {
552            tracing::warn!(
553                target: "libsy",
554                kind = ?error.kind(),
555                "vgr checker could not create its private temporary directory"
556            );
557            return None;
558        }
559        let attempt_file = control.path().join(ATTEMPT_FILE);
560        let task_file = control.path().join(TASK_FILE);
561        if let Err(error) = tokio::fs::write(&attempt_file, attempt).await {
562            tracing::warn!(
563                target: "libsy",
564                kind = ?error.kind(),
565                "vgr checker could not write its attempt control file"
566            );
567            return None;
568        }
569        if let Err(error) = tokio::fs::write(&task_file, task_text).await {
570            tracing::warn!(
571                target: "libsy",
572                kind = ?error.kind(),
573                "vgr checker could not write its task control file"
574            );
575            return None;
576        }
577
578        let pinned_path = self.tests_path();
579        let pinned = self.pinned.clone();
580        let limits = SnapshotLimits::from(&self.config);
581        let (permit, prepared) = match blocking_with_permit(permit, move || {
582            prepare_run_tests(&pinned_path, &pinned, limits)
583        })
584        .await
585        {
586            Ok(result) => result,
587            Err(_join) => {
588                tracing::warn!(
589                    target: "libsy",
590                    "vgr checker snapshot worker did not complete"
591                );
592                return None;
593            }
594        };
595        let run_tests = match prepared {
596            Ok(run_tests) => run_tests,
597            Err(error) => {
598                log_tree_error("pre-run", &error);
599                return None;
600            }
601        };
602
603        let passed = match self
604            .run_command(&workspace, &run_tests, &attempt_file, &task_file, &tmp)
605            .await
606        {
607            Ok(passed) => passed,
608            Err(error) => {
609                tracing::warn!(
610                    target: "libsy",
611                    kind = ?error.kind(),
612                    "vgr checker run did not complete"
613                );
614                return None;
615            }
616        };
617
618        let pinned_path = self.tests_path();
619        let run_tests_path = run_tests.path().to_path_buf();
620        let run_baseline = run_tests.baseline.clone();
621        let pinned = self.pinned.clone();
622        let (_permit, verified) = match blocking_with_permit(permit, move || {
623            verify_after_run(
624                &pinned_path,
625                &pinned,
626                &run_tests_path,
627                &run_baseline,
628                limits,
629            )
630        })
631        .await
632        {
633            Ok(result) => result,
634            Err(_join) => {
635                tracing::warn!(
636                    target: "libsy",
637                    "vgr checker verification worker did not complete"
638                );
639                return None;
640            }
641        };
642        match verified {
643            Ok(()) => Some(passed),
644            Err(error) => {
645                log_tree_error("post-run", &error);
646                None
647            }
648        }
649    }
650
651    /// Runs the configured argv in the candidate workspace.
652    async fn run_command(
653        &self,
654        workspace: &CandidateWorkspace,
655        run_tests: &RunTests,
656        attempt_file: &Path,
657        task_file: &Path,
658        tmp: &Path,
659    ) -> std::io::Result<bool> {
660        let work = workspace.path();
661        let tests = run_tests.path();
662        let substituted = self
663            .config
664            .command
665            .iter()
666            .map(|argument| {
667                argument
668                    .replace("{tests}", &tests.to_string_lossy())
669                    .replace("{workdir}", &work.to_string_lossy())
670            })
671            .collect::<Vec<_>>();
672        let (program, arguments) = substituted
673            .split_first()
674            .ok_or_else(|| std::io::Error::other("checker command is empty"))?;
675
676        let mut command = tokio::process::Command::new(program);
677        command
678            .args(arguments)
679            .current_dir(work)
680            // A whitelist, not the router's environment: the child has no
681            // reason to inherit credentials the router holds.
682            .env_clear();
683        for (key, value) in &self.config.env {
684            command.env(key, value);
685        }
686        // Protected values are intentionally last as defense in depth. Setup
687        // rejects these keys too, but no future construction path may reverse
688        // host ownership of the execution contract.
689        command
690            .env("PATH", &self.protected_path)
691            .env("HOME", work)
692            .env("TMPDIR", tmp)
693            .env("LANG", "C.UTF-8")
694            .env("TESTS_DIR", tests)
695            .env("ATTEMPT_FILE", attempt_file)
696            .env("TASK_FILE", task_file)
697            .env(WORKSPACE_ENV, work)
698            .stdin(std::process::Stdio::null())
699            // Discarded rather than captured. Output derived from the attempt
700            // must not reach the router's logs.
701            .stdout(std::process::Stdio::null())
702            .stderr(std::process::Stdio::null())
703            .kill_on_drop(true);
704        #[cfg(windows)]
705        command
706            .env("USERPROFILE", work)
707            .env("TEMP", tmp)
708            .env("TMP", tmp);
709        #[cfg(windows)]
710        for (key, value) in windows_host_environment() {
711            command.env(key, value);
712        }
713        configure_process_tree(&mut command);
714
715        let mut child = command.spawn()?;
716        // Declared after the child so it terminates descendants before
717        // `kill_on_drop` handles the direct child during cancellation.
718        let _reaper = ProcessTreeReaper::attach(&child)?;
719        let status = child.wait().await?;
720        tracing::debug!(
721            target: "libsy",
722            status = status.code(),
723            "vgr checker run"
724        );
725        Ok(status.success())
726    }
727}
728
729#[async_trait::async_trait]
730impl Checker for PinnedChecker {
731    async fn check(&self, request: CheckerRequest<'_>) -> Option<bool> {
732        if request.manifest_identity != self.manifest.sha {
733            tracing::warn!(target: "libsy", "vgr checker manifest identity mismatch");
734            return None;
735        }
736        let timeout = self.config.timeout.min(request.remaining);
737        match tokio::time::timeout(timeout, self.check_once(request.task_text, request.attempt))
738            .await
739        {
740            Ok(verdict) => verdict,
741            Err(_elapsed) => {
742                tracing::warn!(target: "libsy", "vgr checker timed out");
743                None
744            }
745        }
746    }
747}
748
749/// Holds the private tests used by exactly one run and their mutation baseline.
750struct RunTests {
751    owner: TempDir,
752    path: PathBuf,
753    baseline: TreeSnapshot,
754}
755
756impl RunTests {
757    fn path(&self) -> &Path {
758        &self.path
759    }
760}
761
762/// Moves admission into blocking work so cancellation cannot release it early.
763async fn blocking_with_permit<T, F>(
764    permit: OwnedSemaphorePermit,
765    work: F,
766) -> Result<(OwnedSemaphorePermit, T), tokio::task::JoinError>
767where
768    T: Send + 'static,
769    F: FnOnce() -> T + Send + 'static,
770{
771    tokio::task::spawn_blocking(move || {
772        let result = work();
773        (permit, result)
774    })
775    .await
776}
777
778/// Builds and verifies the one test copy exposed to a command.
779fn prepare_run_tests(
780    pinned_path: &Path,
781    pinned: &TreeSnapshot,
782    limits: SnapshotLimits,
783) -> Result<RunTests, TreeError> {
784    ensure_matches(snapshot_tree(pinned_path, limits)?, pinned)?;
785    let owner = TempDir::with_prefix("vgr-checker-tests-").map_err(TreeError::Io)?;
786    let tests_path = owner.path().join("tests");
787    copy_tree(pinned_path, &tests_path, limits)?;
788    set_read_only(&tests_path)?;
789    let baseline = snapshot_tree(&tests_path, limits)?;
790    if baseline.entries != pinned.entries {
791        return Err(TreeError::Mismatch);
792    }
793    // A source mutation racing the copy is caught even if the copied bytes look
794    // self-consistent.
795    ensure_matches(snapshot_tree(pinned_path, limits)?, pinned)?;
796    Ok(RunTests {
797        owner,
798        path: tests_path,
799        baseline,
800    })
801}
802
803/// Verifies both the private run copy and the unexposed pinned source.
804fn verify_after_run(
805    pinned_path: &Path,
806    pinned: &TreeSnapshot,
807    run_tests_path: &Path,
808    run_baseline: &TreeSnapshot,
809    limits: SnapshotLimits,
810) -> Result<(), TreeError> {
811    ensure_matches(snapshot_tree(run_tests_path, limits)?, run_baseline)?;
812    ensure_matches(snapshot_tree(pinned_path, limits)?, pinned)
813}
814
815fn ensure_matches(current: TreeSnapshot, expected: &TreeSnapshot) -> Result<(), TreeError> {
816    if current == *expected {
817        Ok(())
818    } else {
819        Err(TreeError::Mismatch)
820    }
821}
822
823fn validate_config(config: &CheckerConfig) -> Result<(), CheckerSetupError> {
824    if config.command.is_empty() {
825        return Err(CheckerSetupError::EmptyCommand);
826    }
827    if config.sandbox_attestation != SANDBOX_ATTESTATION {
828        return Err(CheckerSetupError::NotAttested);
829    }
830    if config.max_snapshot_entries == 0 || config.max_snapshot_bytes == 0 {
831        return Err(CheckerSetupError::InvalidSnapshotLimits);
832    }
833    let identity = config.workspace_provider.manifest_identity();
834    if identity.is_empty() || identity.len() > 4096 || identity.contains('\0') {
835        return Err(CheckerSetupError::InvalidWorkspaceIdentity);
836    }
837    for (key, value) in &config.env {
838        if is_protected_environment_key(key) {
839            return Err(CheckerSetupError::ReservedEnvironmentKey(key.clone()));
840        }
841        if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
842            return Err(CheckerSetupError::InvalidEnvironmentEntry(key.clone()));
843        }
844    }
845    Ok(())
846}
847
848fn validate_materializer_config(
849    config: &CommandWorkspaceProviderConfig,
850) -> Result<(), CommandWorkspaceProviderSetupError> {
851    if config.command.is_empty() {
852        return Err(CommandWorkspaceProviderSetupError::EmptyCommand);
853    }
854    for (key, value) in &config.env {
855        if is_protected_environment_key(key) {
856            return Err(CommandWorkspaceProviderSetupError::ReservedEnvironmentKey(
857                key.clone(),
858            ));
859        }
860        if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') {
861            return Err(CommandWorkspaceProviderSetupError::InvalidEnvironmentEntry(
862                key.clone(),
863            ));
864        }
865    }
866    Ok(())
867}
868
869fn is_protected_environment_key(key: &str) -> bool {
870    #[cfg(unix)]
871    {
872        PROTECTED_ENV.contains(&key)
873    }
874    #[cfg(windows)]
875    {
876        PROTECTED_ENV
877            .iter()
878            .any(|protected| protected.eq_ignore_ascii_case(key))
879    }
880}
881
882#[derive(Clone, Copy)]
883struct SnapshotLimits {
884    entries: usize,
885    bytes: u64,
886}
887
888impl From<&CheckerConfig> for SnapshotLimits {
889    fn from(config: &CheckerConfig) -> Self {
890        Self {
891            entries: config.max_snapshot_entries,
892            bytes: config.max_snapshot_bytes,
893        }
894    }
895}
896
897#[derive(Clone, Debug, Eq, PartialEq)]
898struct TreeSnapshot {
899    entries: BTreeMap<PathBuf, StableEntry>,
900    stamps: BTreeMap<PathBuf, MutationStamp>,
901}
902
903#[derive(Clone, Debug, Eq, PartialEq)]
904enum StableEntry {
905    Directory,
906    File(String),
907}
908
909/// Volatile tamper evidence, deliberately excluded from the public manifest.
910#[derive(Debug)]
911enum TreeError {
912    Io(std::io::Error),
913    Unsupported(PathBuf),
914    EntryLimit(usize),
915    ByteLimit(u64),
916    ChangedDuringSnapshot,
917    Mismatch,
918}
919
920impl From<std::io::Error> for TreeError {
921    fn from(error: std::io::Error) -> Self {
922        Self::Io(error)
923    }
924}
925
926impl From<TreeError> for CheckerSetupError {
927    fn from(error: TreeError) -> Self {
928        match error {
929            TreeError::Io(error) => Self::Snapshot(error),
930            TreeError::Unsupported(path) => Self::UnsupportedEntry(path),
931            TreeError::EntryLimit(limit) => Self::SnapshotEntryLimit(limit),
932            TreeError::ByteLimit(limit) => Self::SnapshotByteLimit(limit),
933            TreeError::ChangedDuringSnapshot => Self::Snapshot(std::io::Error::other(
934                "test suite changed while it was being snapshotted",
935            )),
936            TreeError::Mismatch => {
937                Self::Snapshot(std::io::Error::other("test suite snapshot did not match"))
938            }
939        }
940    }
941}
942
943fn log_tree_error(stage: &'static str, error: &TreeError) {
944    let reason = match error {
945        TreeError::Io(error) => match error.kind() {
946            std::io::ErrorKind::NotFound => "not_found",
947            std::io::ErrorKind::PermissionDenied => "permission_denied",
948            _ => "io",
949        },
950        TreeError::Unsupported(_) => "unsupported_entry",
951        TreeError::EntryLimit(_) => "entry_limit",
952        TreeError::ByteLimit(_) => "byte_limit",
953        TreeError::ChangedDuringSnapshot => "changed_during_snapshot",
954        TreeError::Mismatch => "manifest_mismatch",
955    };
956    tracing::warn!(
957        target: "libsy",
958        stage,
959        reason,
960        "vgr checker test snapshot could not be verified"
961    );
962}
963
964#[derive(Clone, Copy)]
965struct Usage {
966    limits: SnapshotLimits,
967    entries: usize,
968    bytes: u64,
969}
970
971impl Usage {
972    fn new(limits: SnapshotLimits) -> Self {
973        Self {
974            limits,
975            entries: 0,
976            bytes: 0,
977        }
978    }
979
980    fn add_entry(&mut self) -> Result<(), TreeError> {
981        self.entries = self
982            .entries
983            .checked_add(1)
984            .ok_or(TreeError::EntryLimit(self.limits.entries))?;
985        if self.entries > self.limits.entries {
986            return Err(TreeError::EntryLimit(self.limits.entries));
987        }
988        Ok(())
989    }
990
991    fn check_file_size(&self, bytes: u64) -> Result<(), TreeError> {
992        let total = self
993            .bytes
994            .checked_add(bytes)
995            .ok_or(TreeError::ByteLimit(self.limits.bytes))?;
996        if total > self.limits.bytes {
997            return Err(TreeError::ByteLimit(self.limits.bytes));
998        }
999        Ok(())
1000    }
1001
1002    fn add_bytes(&mut self, bytes: usize) -> Result<(), TreeError> {
1003        self.bytes = self
1004            .bytes
1005            .checked_add(bytes as u64)
1006            .ok_or(TreeError::ByteLimit(self.limits.bytes))?;
1007        if self.bytes > self.limits.bytes {
1008            return Err(TreeError::ByteLimit(self.limits.bytes));
1009        }
1010        Ok(())
1011    }
1012}
1013
1014/// Copies a regular-file/directory tree with bounded streaming I/O.
1015fn copy_tree(from: &Path, to: &Path, limits: SnapshotLimits) -> Result<(), TreeError> {
1016    let root_metadata = std::fs::symlink_metadata(from)?;
1017    if !is_directory(&root_metadata) {
1018        return Err(TreeError::Unsupported(from.to_path_buf()));
1019    }
1020    std::fs::create_dir(to)?;
1021    let mut usage = Usage::new(limits);
1022    usage.add_entry()?;
1023    let mut directories = vec![(from.to_path_buf(), to.to_path_buf())];
1024    while let Some((source, target)) = directories.pop() {
1025        let before = std::fs::symlink_metadata(&source)?;
1026        if !is_directory(&before) {
1027            return Err(TreeError::Unsupported(source));
1028        }
1029        let before_stamp = mutation_stamp_path(&source, &before)?;
1030        for entry in std::fs::read_dir(&source)? {
1031            let entry = entry?;
1032            let source_path = entry.path();
1033            let target_path = target.join(entry.file_name());
1034            let metadata = std::fs::symlink_metadata(&source_path)?;
1035            usage.add_entry()?;
1036            if is_directory(&metadata) {
1037                std::fs::create_dir(&target_path)?;
1038                directories.push((source_path, target_path));
1039            } else if is_regular_file(&metadata) {
1040                copy_file(&source_path, &target_path, &metadata, &mut usage)?;
1041            } else {
1042                return Err(TreeError::Unsupported(source_path));
1043            }
1044        }
1045        let after = std::fs::symlink_metadata(&source)?;
1046        if mutation_stamp_path(&source, &after)? != before_stamp {
1047            return Err(TreeError::ChangedDuringSnapshot);
1048        }
1049    }
1050    Ok(())
1051}
1052
1053fn copy_file(
1054    source: &Path,
1055    target: &Path,
1056    metadata: &std::fs::Metadata,
1057    usage: &mut Usage,
1058) -> Result<(), TreeError> {
1059    usage.check_file_size(metadata.len())?;
1060    let expected_stamp = mutation_stamp_path(source, metadata)?;
1061    let mut input = open_regular_file(source)?;
1062    let opened = input.metadata()?;
1063    let opened_stamp = mutation_stamp_file(&input)?;
1064    if !is_regular_file(&opened) || opened_stamp != expected_stamp {
1065        return Err(TreeError::ChangedDuringSnapshot);
1066    }
1067    let mut output = OpenOptions::new()
1068        .write(true)
1069        .create_new(true)
1070        .open(target)?;
1071    let mut buffer = [0_u8; HASH_CHUNK_BYTES];
1072    loop {
1073        let read = input.read(&mut buffer)?;
1074        if read == 0 {
1075            break;
1076        }
1077        usage.add_bytes(read)?;
1078        output.write_all(&buffer[..read])?;
1079    }
1080    std::fs::set_permissions(target, metadata.permissions())?;
1081    if mutation_stamp_file(&input)? != opened_stamp {
1082        return Err(TreeError::ChangedDuringSnapshot);
1083    }
1084    Ok(())
1085}
1086
1087/// Marks every regular file in the tree read-only and rejects every other kind.
1088fn set_read_only(root: &Path) -> Result<(), TreeError> {
1089    let metadata = std::fs::symlink_metadata(root)?;
1090    if !is_directory(&metadata) {
1091        return Err(TreeError::Unsupported(root.to_path_buf()));
1092    }
1093    let mut directories = vec![root.to_path_buf()];
1094    while let Some(directory) = directories.pop() {
1095        for entry in std::fs::read_dir(directory)? {
1096            let entry = entry?;
1097            let path = entry.path();
1098            let metadata = std::fs::symlink_metadata(&path)?;
1099            if is_directory(&metadata) {
1100                directories.push(path);
1101            } else if is_regular_file(&metadata) {
1102                let mut permissions = metadata.permissions();
1103                permissions.set_readonly(true);
1104                std::fs::set_permissions(path, permissions)?;
1105            } else {
1106                return Err(TreeError::Unsupported(path));
1107            }
1108        }
1109    }
1110    Ok(())
1111}
1112
1113/// Hashes the complete namespace and captures separate mutation stamps.
1114fn snapshot_tree(root: &Path, limits: SnapshotLimits) -> Result<TreeSnapshot, TreeError> {
1115    let root_metadata = std::fs::symlink_metadata(root)?;
1116    if !is_directory(&root_metadata) {
1117        return Err(TreeError::Unsupported(root.to_path_buf()));
1118    }
1119    let mut usage = Usage::new(limits);
1120    let mut entries = BTreeMap::new();
1121    let mut stamps = BTreeMap::new();
1122    let mut directories = vec![root.to_path_buf()];
1123    while let Some(directory) = directories.pop() {
1124        let relative = relative_path(root, &directory)?;
1125        let before = std::fs::symlink_metadata(&directory)?;
1126        if !is_directory(&before) {
1127            return Err(TreeError::Unsupported(directory));
1128        }
1129        usage.add_entry()?;
1130        entries.insert(relative.clone(), StableEntry::Directory);
1131        let before_stamp = mutation_stamp_path(&directory, &before)?;
1132        stamps.insert(relative, before_stamp);
1133        for entry in std::fs::read_dir(&directory)? {
1134            let entry = entry?;
1135            let path = entry.path();
1136            let metadata = std::fs::symlink_metadata(&path)?;
1137            if is_directory(&metadata) {
1138                directories.push(path);
1139            } else if is_regular_file(&metadata) {
1140                usage.add_entry()?;
1141                let relative = relative_path(root, &path)?;
1142                let (digest, stamp) = hash_file(&path, &metadata, &mut usage)?;
1143                entries.insert(relative.clone(), StableEntry::File(digest));
1144                stamps.insert(relative, stamp);
1145            } else {
1146                return Err(TreeError::Unsupported(path));
1147            }
1148        }
1149        let after = std::fs::symlink_metadata(&directory)?;
1150        if mutation_stamp_path(&directory, &after)? != before_stamp {
1151            return Err(TreeError::ChangedDuringSnapshot);
1152        }
1153    }
1154    Ok(TreeSnapshot { entries, stamps })
1155}
1156
1157fn relative_path(root: &Path, path: &Path) -> Result<PathBuf, TreeError> {
1158    path.strip_prefix(root)
1159        .map(Path::to_path_buf)
1160        .map_err(|_| TreeError::Io(std::io::Error::other("snapshot entry escaped its root")))
1161}
1162
1163fn hash_file(
1164    path: &Path,
1165    metadata: &std::fs::Metadata,
1166    usage: &mut Usage,
1167) -> Result<(String, MutationStamp), TreeError> {
1168    usage.check_file_size(metadata.len())?;
1169    let expected_stamp = mutation_stamp_path(path, metadata)?;
1170    let mut file = open_regular_file(path)?;
1171    let opened = file.metadata()?;
1172    let stamp = mutation_stamp_file(&file)?;
1173    if !is_regular_file(&opened) || stamp != expected_stamp {
1174        return Err(TreeError::ChangedDuringSnapshot);
1175    }
1176    let mut context = ring::digest::Context::new(&ring::digest::SHA256);
1177    let mut buffer = [0_u8; HASH_CHUNK_BYTES];
1178    loop {
1179        let read = file.read(&mut buffer)?;
1180        if read == 0 {
1181            break;
1182        }
1183        usage.add_bytes(read)?;
1184        context.update(&buffer[..read]);
1185    }
1186    if mutation_stamp_file(&file)? != stamp {
1187        return Err(TreeError::ChangedDuringSnapshot);
1188    }
1189    Ok((hex(context.finish()), stamp))
1190}
1191
1192/// One stable hash over the suite and complete execution contract.
1193fn manifest_sha(
1194    entries: &BTreeMap<PathBuf, StableEntry>,
1195    config: &CheckerConfig,
1196    protected_path: &str,
1197) -> String {
1198    let mut context = ring::digest::Context::new(&ring::digest::SHA256);
1199    digest_field(&mut context, b"vgr-pinned-checker-manifest-v2");
1200    for (path, entry) in entries {
1201        digest_field(&mut context, &os_str_bytes(path.as_os_str()));
1202        match entry {
1203            StableEntry::Directory => digest_field(&mut context, b"directory"),
1204            StableEntry::File(digest) => {
1205                digest_field(&mut context, b"file");
1206                digest_field(&mut context, digest.as_bytes());
1207            }
1208        }
1209    }
1210    for argument in &config.command {
1211        digest_field(&mut context, argument.as_bytes());
1212    }
1213    digest_field(
1214        &mut context,
1215        config.workspace_provider.manifest_identity().as_bytes(),
1216    );
1217    digest_field(
1218        &mut context,
1219        config.timeout.as_nanos().to_string().as_bytes(),
1220    );
1221    digest_field(
1222        &mut context,
1223        config.max_snapshot_entries.to_string().as_bytes(),
1224    );
1225    digest_field(
1226        &mut context,
1227        config.max_snapshot_bytes.to_string().as_bytes(),
1228    );
1229    for (key, value) in manifest_environment(config, protected_path) {
1230        digest_field(&mut context, key.as_bytes());
1231        digest_field(&mut context, value.as_bytes());
1232    }
1233    hex(context.finish())
1234}
1235
1236fn manifest_environment(config: &CheckerConfig, protected_path: &str) -> BTreeMap<String, String> {
1237    let mut environment = config.env.iter().cloned().collect::<BTreeMap<_, _>>();
1238    environment.insert("PATH".into(), protected_path.to_string());
1239    environment.insert("LANG".into(), "C.UTF-8".into());
1240    environment.insert("HOME".into(), "{workspace}".into());
1241    environment.insert("TMPDIR".into(), "{control}/tmp".into());
1242    environment.insert("TESTS_DIR".into(), "{private-tests}".into());
1243    environment.insert("ATTEMPT_FILE".into(), "{control}/attempt.txt".into());
1244    environment.insert("TASK_FILE".into(), "{control}/task.txt".into());
1245    environment.insert(WORKSPACE_ENV.into(), "{workspace}".into());
1246    #[cfg(windows)]
1247    {
1248        environment.insert("TEMP".into(), "{control}/tmp".into());
1249        environment.insert("TMP".into(), "{control}/tmp".into());
1250        environment.insert("USERPROFILE".into(), "{workspace}".into());
1251        environment.extend(windows_host_environment());
1252    }
1253    environment
1254}
1255
1256fn current_path() -> String {
1257    std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into())
1258}
1259
1260#[cfg(windows)]
1261fn windows_host_environment() -> BTreeMap<String, String> {
1262    WINDOWS_HOST_ENV
1263        .into_iter()
1264        .filter_map(|key| {
1265            std::env::var(key)
1266                .ok()
1267                .map(|value| (key.to_string(), value))
1268        })
1269        .collect()
1270}
1271
1272fn materializer_manifest_identity(
1273    config: &CommandWorkspaceProviderConfig,
1274    protected_path: &str,
1275) -> String {
1276    let mut context = ring::digest::Context::new(&ring::digest::SHA256);
1277    digest_field(&mut context, b"vgr-command-workspace-provider-v1");
1278    for argument in &config.command {
1279        digest_field(&mut context, argument.as_bytes());
1280    }
1281    digest_field(
1282        &mut context,
1283        config.timeout.as_nanos().to_string().as_bytes(),
1284    );
1285    for (key, value) in materializer_manifest_environment(config, protected_path) {
1286        digest_field(&mut context, key.as_bytes());
1287        digest_field(&mut context, value.as_bytes());
1288    }
1289    format!("command-workspace-provider-v1:{}", hex(context.finish()))
1290}
1291
1292fn materializer_manifest_environment(
1293    config: &CommandWorkspaceProviderConfig,
1294    protected_path: &str,
1295) -> BTreeMap<String, String> {
1296    let mut environment = config.env.iter().cloned().collect::<BTreeMap<_, _>>();
1297    environment.remove("TESTS_DIR");
1298    environment.insert("PATH".into(), protected_path.to_string());
1299    environment.insert("LANG".into(), "C.UTF-8".into());
1300    environment.insert("HOME".into(), "{workspace}".into());
1301    environment.insert("TMPDIR".into(), "{control}/tmp".into());
1302    environment.insert("ATTEMPT_FILE".into(), "{control}/attempt.txt".into());
1303    environment.insert("TASK_FILE".into(), "{control}/task.txt".into());
1304    environment.insert(WORKSPACE_ENV.into(), "{workspace}".into());
1305    #[cfg(windows)]
1306    {
1307        environment.insert("TEMP".into(), "{control}/tmp".into());
1308        environment.insert("TMP".into(), "{control}/tmp".into());
1309        environment.insert("USERPROFILE".into(), "{workspace}".into());
1310        environment.extend(windows_host_environment());
1311    }
1312    environment
1313}
1314
1315fn digest_field(context: &mut ring::digest::Context, value: &[u8]) {
1316    context.update(&(value.len() as u64).to_le_bytes());
1317    context.update(value);
1318}
1319
1320/// Renders a digest as lower-case hex.
1321fn hex(digest: ring::digest::Digest) -> String {
1322    digest
1323        .as_ref()
1324        .iter()
1325        .map(|byte| format!("{byte:02x}"))
1326        .collect()
1327}
1328
1329#[cfg(all(test, unix))]
1330mod tests;
1331#[cfg(all(test, windows))]
1332#[path = "checker/tests_windows.rs"]
1333mod tests;