1use 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
51const 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
94pub const SANDBOX_ATTESTATION: &str = "vgr-checker-runs-in-deployment-sandbox";
101
102pub struct CandidateWorkspace {
104 root: TempDir,
105}
106
107impl CandidateWorkspace {
108 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 pub fn path(&self) -> &Path {
122 self.root.path()
123 }
124}
125
126#[async_trait::async_trait]
132pub trait WorkspaceProvider: Send + Sync {
133 fn manifest_identity(&self) -> &str;
135
136 async fn materialize(
138 &self,
139 task_text: &str,
140 attempt: &str,
141 ) -> std::io::Result<CandidateWorkspace>;
142}
143
144#[derive(Clone)]
146pub struct CommandWorkspaceProviderConfig {
147 pub command: Vec<String>,
149 pub timeout: Duration,
151 pub env: Vec<(String, String)>,
153}
154
155impl CommandWorkspaceProviderConfig {
156 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#[derive(Debug, thiserror::Error)]
184pub enum CommandWorkspaceProviderSetupError {
185 #[error("candidate materialize command must be a non-empty argv list")]
187 EmptyCommand,
188 #[error("candidate materializer environment key {0:?} is reserved for the host")]
190 ReservedEnvironmentKey(String),
191 #[error("candidate materializer environment entry {0:?} is invalid")]
193 InvalidEnvironmentEntry(String),
194}
195
196pub struct CommandWorkspaceProvider {
202 config: CommandWorkspaceProviderConfig,
203 manifest_identity: String,
204 protected_path: String,
205}
206
207impl CommandWorkspaceProvider {
208 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 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 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#[derive(Clone)]
321pub struct CheckerConfig {
322 pub tests_dir: PathBuf,
324 pub command: Vec<String>,
329 pub timeout: Duration,
331 pub env: Vec<(String, String)>,
333 pub max_snapshot_entries: usize,
335 pub max_snapshot_bytes: u64,
337 pub workspace_provider: Arc<dyn WorkspaceProvider>,
339 pub sandbox_attestation: String,
341}
342
343impl CheckerConfig {
344 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#[derive(Debug, thiserror::Error)]
389pub enum CheckerSetupError {
390 #[error("checker command must be a non-empty argv list")]
392 EmptyCommand,
393 #[error("checker requires the sandbox attestation {SANDBOX_ATTESTATION:?}")]
395 NotAttested,
396 #[error("checker environment key {0:?} is reserved for the host")]
398 ReservedEnvironmentKey(String),
399 #[error("checker environment entry {0:?} is invalid")]
401 InvalidEnvironmentEntry(String),
402 #[error("checker workspace provider requires a stable non-empty manifest identity")]
404 InvalidWorkspaceIdentity,
405 #[error("checker snapshot byte and entry limits must be non-zero")]
407 InvalidSnapshotLimits,
408 #[error("checker could not snapshot its test suite: {0}")]
410 Snapshot(#[source] std::io::Error),
411 #[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 #[error("checker test suite exceeds the {0}-entry snapshot limit")]
419 SnapshotEntryLimit(usize),
420 #[error("checker test suite exceeds the {0}-byte snapshot limit")]
422 SnapshotByteLimit(u64),
423}
424
425#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct Manifest {
432 entries: BTreeMap<PathBuf, StableEntry>,
433 pub sha: String,
435}
436
437pub struct PinnedChecker {
443 config: CheckerConfig,
444 snapshot: TempDir,
446 pinned: TreeSnapshot,
447 manifest: Manifest,
448 admission: Arc<Semaphore>,
449 protected_path: String,
450}
451
452impl PinnedChecker {
453 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 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 pub fn manifest(&self) -> &Manifest {
486 &self.manifest
487 }
488
489 fn tests_path(&self) -> PathBuf {
491 self.snapshot.path().join("tests")
492 }
493
494 pub fn manifest_identity(&self) -> &str {
496 &self.manifest.sha
497 }
498
499 #[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 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 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 .env_clear();
683 for (key, value) in &self.config.env {
684 command.env(key, value);
685 }
686 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 .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 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
749struct 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
762async 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
778fn 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 ensure_matches(snapshot_tree(pinned_path, limits)?, pinned)?;
796 Ok(RunTests {
797 owner,
798 path: tests_path,
799 baseline,
800 })
801}
802
803fn 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#[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
1014fn 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
1087fn 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
1113fn 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
1192fn 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
1320fn 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;