1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::cli::{Cli, CliBackendKind, CliExecutionMode};
5use crate::config::{
6 BackendKind, ImageConfig, LoadedConfig,
7 model::{
8 CacheConfig, Config, EnvironmentConfig, ExecutionMode, MountType, ProfileConfig,
9 ProfileRole, SecretConfig,
10 },
11};
12use crate::dispatch;
13use crate::error::SboxError;
14
15#[derive(Debug, Clone)]
16pub struct ExecutionPlan {
17 pub command: Vec<String>,
18 pub command_string: String,
19 pub backend: BackendKind,
20 pub image: ResolvedImage,
21 pub profile_name: String,
22 pub profile_source: ProfileSource,
23 pub mode: ExecutionMode,
24 pub mode_source: ModeSource,
25 pub workspace: ResolvedWorkspace,
26 pub policy: ResolvedPolicy,
27 pub environment: ResolvedEnvironment,
28 pub mounts: Vec<ResolvedMount>,
29 pub caches: Vec<ResolvedCache>,
30 pub secrets: Vec<ResolvedSecret>,
31 pub user: ResolvedUser,
32 pub audit: ExecutionAudit,
33}
34
35#[derive(Debug, Clone)]
36pub struct ExecutionAudit {
37 pub install_style: bool,
38 pub trusted_image_required: bool,
39 pub sensitive_pass_through_vars: Vec<String>,
40 pub lockfile: LockfileAudit,
41 pub pre_run: Vec<Vec<String>>,
44}
45
46#[derive(Debug, Clone)]
47pub struct LockfileAudit {
48 pub applicable: bool,
49 pub required: bool,
50 pub present: bool,
51 pub expected_files: Vec<String>,
52}
53
54#[derive(Debug, Clone)]
55pub struct ResolvedImage {
56 pub description: String,
57 pub source: ResolvedImageSource,
58 pub trust: ImageTrust,
59 pub verify_signature: bool,
60}
61
62#[derive(Debug, Clone)]
63pub enum ResolvedImageSource {
64 Reference(String),
65 Build { recipe_path: PathBuf, tag: String },
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum ImageTrust {
70 PinnedDigest,
71 MutableReference,
72 LocalBuild,
73}
74
75#[derive(Debug, Clone)]
76pub struct ResolvedWorkspace {
77 pub root: PathBuf,
78 pub invocation_dir: PathBuf,
79 pub effective_host_dir: PathBuf,
80 pub mount: String,
81 pub sandbox_cwd: String,
82 pub cwd_mapping: CwdMapping,
83}
84
85#[derive(Debug, Clone)]
86pub enum CwdMapping {
87 InvocationMapped,
88 WorkspaceRootFallback,
89}
90
91#[derive(Debug, Clone)]
92pub struct ResolvedPolicy {
93 pub network: String,
94 pub writable: bool,
95 pub ports: Vec<String>,
96 pub no_new_privileges: bool,
97 pub read_only_rootfs: bool,
98 pub reuse_container: bool,
99 pub reusable_session_name: Option<String>,
100 pub cap_drop: Vec<String>,
101 pub cap_add: Vec<String>,
102 pub pull_policy: Option<String>,
103 pub network_allow: Vec<(String, String)>,
106 pub network_allow_patterns: Vec<String>,
109}
110
111#[derive(Debug, Clone)]
112pub struct ResolvedEnvironment {
113 pub variables: Vec<ResolvedEnvVar>,
114 pub denied: Vec<String>,
115}
116
117#[derive(Debug, Clone)]
118pub struct ResolvedEnvVar {
119 pub name: String,
120 pub value: String,
121 pub source: EnvVarSource,
122}
123
124#[derive(Debug, Clone)]
125pub enum EnvVarSource {
126 PassThrough,
127 Set,
128}
129
130#[derive(Debug, Clone)]
131pub struct ResolvedMount {
132 pub kind: String,
133 pub source: Option<PathBuf>,
134 pub target: String,
135 pub read_only: bool,
136 pub is_workspace: bool,
137 pub create: bool,
139}
140
141#[derive(Debug, Clone)]
142pub struct ResolvedCache {
143 pub name: String,
144 pub target: String,
145 pub source: Option<String>,
146 pub read_only: bool,
147}
148
149#[derive(Debug, Clone)]
150pub struct ResolvedSecret {
151 pub name: String,
152 pub source: String,
153 pub target: String,
154}
155
156#[derive(Debug, Clone)]
157pub enum ResolvedUser {
158 Default,
159 KeepId,
160 Explicit { uid: u32, gid: u32 },
161}
162
163#[derive(Debug, Clone)]
164pub enum ProfileSource {
165 CliOverride,
166 ExecSubcommand,
167 Dispatch { rule_name: String, pattern: String },
168 DefaultProfile,
169 ImplementationDefault,
170}
171
172#[derive(Debug, Clone)]
173pub enum ModeSource {
174 CliOverride,
175 Profile,
176}
177
178#[derive(Debug, Clone, Copy)]
179#[allow(dead_code)]
180pub enum ResolutionTarget<'a> {
181 Run,
182 Exec { profile: &'a str },
183 Shell,
184 Plan,
185}
186
187pub fn resolve_execution_plan(
188 cli: &Cli,
189 loaded: &LoadedConfig,
190 target: ResolutionTarget<'_>,
191 command: &[String],
192) -> Result<ExecutionPlan, SboxError> {
193 let config = &loaded.config;
194 let workspace = config.workspace.as_ref().expect("validated workspace");
195 let environment = config.environment.as_ref().cloned().unwrap_or_default();
196 let profile_resolution = resolve_profile(cli, config, target, command)?;
197 let profile = config
198 .profiles
199 .get(&profile_resolution.name)
200 .expect("profile existence validated during resolution");
201 let (mode, mode_source) = resolve_mode(cli, profile);
202 let backend = resolve_backend(cli, config);
203 let resolved_workspace = resolve_workspace(
204 loaded,
205 workspace
206 .mount
207 .as_deref()
208 .expect("validated workspace mount"),
209 );
210 let image = resolve_image(
211 cli,
212 config.image.as_ref().expect("validated image"),
213 profile.image.as_ref(),
214 &loaded.workspace_root,
215 )?;
216 let policy = resolve_policy(
217 config,
218 &profile_resolution.name,
219 profile,
220 &mode,
221 &resolved_workspace.root,
222 );
223 let environment = resolve_environment(&environment);
224 let mounts = resolve_mounts(
225 config,
226 profile,
227 &resolved_workspace.root,
228 &resolved_workspace.mount,
229 policy.writable,
230 );
231 let caches = resolve_caches(&config.caches);
232 let secrets = resolve_secrets(
233 &config.secrets,
234 &profile_resolution.name,
235 profile.role.as_ref(),
236 );
237 let rootless = config
238 .runtime
239 .as_ref()
240 .and_then(|rt| rt.rootless)
241 .unwrap_or(true);
242 let user = resolve_user(config, rootless);
243 let install_style = is_install_style(&profile.role, &profile_resolution.name);
244 let audit = ExecutionAudit {
245 install_style,
246 trusted_image_required: profile.require_pinned_image.unwrap_or(false)
247 || config
248 .runtime
249 .as_ref()
250 .and_then(|rt| rt.require_pinned_image)
251 .unwrap_or(false),
252 sensitive_pass_through_vars: resolved_sensitive_pass_through_vars(&environment),
253 lockfile: resolve_lockfile_audit(
254 &profile.lockfile_files,
255 install_style,
256 &resolved_workspace.effective_host_dir,
257 profile.require_lockfile,
258 ),
259 pre_run: parse_pre_run_commands(&profile.pre_run),
260 };
261
262 Ok(ExecutionPlan {
263 command_string: dispatch::command_string(command),
264 command: command.to_vec(),
265 backend,
266 image,
267 profile_name: profile_resolution.name,
268 profile_source: profile_resolution.source,
269 mode,
270 mode_source,
271 workspace: resolved_workspace,
272 policy,
273 environment,
274 mounts,
275 caches,
276 secrets,
277 user,
278 audit,
279 })
280}
281
282struct ProfileResolution {
283 name: String,
284 source: ProfileSource,
285}
286
287fn resolve_profile(
288 cli: &Cli,
289 config: &Config,
290 target: ResolutionTarget<'_>,
291 command: &[String],
292) -> Result<ProfileResolution, SboxError> {
293 if let Some(name) = &cli.profile {
294 return ensure_profile_exists(config, name, ProfileSource::CliOverride);
295 }
296
297 if let ResolutionTarget::Exec { profile } = target {
298 return ensure_profile_exists(config, profile, ProfileSource::ExecSubcommand);
299 }
300
301 if matches!(target, ResolutionTarget::Shell) {
302 if config.profiles.contains_key("default") {
303 return ensure_profile_exists(config, "default", ProfileSource::DefaultProfile);
304 }
305
306 if let Some((name, _)) = config.profiles.first() {
307 return ensure_profile_exists(config, name, ProfileSource::ImplementationDefault);
308 }
309
310 return Err(SboxError::ProfileResolutionFailed {
311 command: "<shell>".to_string(),
312 });
313 }
314
315 let command_string = dispatch::command_string(command);
316 for (rule_name, rule) in &config.dispatch {
317 for pattern in &rule.patterns {
318 if dispatch::matches(pattern, &command_string) {
319 return ensure_profile_exists(
320 config,
321 &rule.profile,
322 ProfileSource::Dispatch {
323 rule_name: rule_name.clone(),
324 pattern: pattern.clone(),
325 },
326 );
327 }
328 }
329 }
330
331 if config.profiles.contains_key("default") {
332 return ensure_profile_exists(config, "default", ProfileSource::DefaultProfile);
333 }
334
335 if let Some((name, _)) = config.profiles.first() {
336 return ensure_profile_exists(config, name, ProfileSource::ImplementationDefault);
337 }
338
339 Err(SboxError::ProfileResolutionFailed {
340 command: command_string,
341 })
342}
343
344fn ensure_profile_exists(
345 config: &Config,
346 name: &str,
347 source: ProfileSource,
348) -> Result<ProfileResolution, SboxError> {
349 if config.profiles.contains_key(name) {
350 Ok(ProfileResolution {
351 name: name.to_string(),
352 source,
353 })
354 } else {
355 Err(SboxError::UnknownProfile {
356 name: name.to_string(),
357 })
358 }
359}
360
361fn resolve_mode(cli: &Cli, profile: &ProfileConfig) -> (ExecutionMode, ModeSource) {
362 match cli.mode {
363 Some(CliExecutionMode::Host) => (ExecutionMode::Host, ModeSource::CliOverride),
364 Some(CliExecutionMode::Sandbox) => (ExecutionMode::Sandbox, ModeSource::CliOverride),
365 None => (profile.mode.clone(), ModeSource::Profile),
366 }
367}
368
369fn resolve_backend(cli: &Cli, config: &Config) -> BackendKind {
370 match cli.backend {
371 Some(CliBackendKind::Podman) => BackendKind::Podman,
372 Some(CliBackendKind::Docker) => BackendKind::Docker,
373 None => config
374 .runtime
375 .as_ref()
376 .and_then(|runtime| runtime.backend.clone())
377 .unwrap_or_else(detect_backend),
378 }
379}
380
381fn detect_backend() -> BackendKind {
382 if which_on_path("podman") {
384 return BackendKind::Podman;
385 }
386 if which_on_path("docker") {
387 return BackendKind::Docker;
388 }
389 BackendKind::Podman
391}
392
393pub(crate) fn which_on_path(name: &str) -> bool {
394 let Some(path_os) = std::env::var_os("PATH") else {
395 return false;
396 };
397 for dir in std::env::split_paths(&path_os) {
398 #[cfg(windows)]
399 {
400 for ext in &[".exe", ".cmd", ".bat"] {
403 let candidate = dir.join(format!("{name}{ext}"));
404 if candidate.is_file() {
405 return true;
406 }
407 }
408 }
409 #[cfg(not(windows))]
410 {
411 use std::os::unix::fs::PermissionsExt;
412 let candidate = dir.join(name);
413 if candidate.is_file()
414 && candidate
415 .metadata()
416 .map(|m| m.permissions().mode() & 0o111 != 0)
417 .unwrap_or(false)
418 {
419 return true;
420 }
421 }
422 }
423 false
424}
425
426fn resolve_image(
427 cli: &Cli,
428 image: &ImageConfig,
429 profile_image: Option<&ImageConfig>,
430 workspace_root: &Path,
431) -> Result<ResolvedImage, SboxError> {
432 if let Some(reference) = &cli.image {
433 return Ok(ResolvedImage {
434 description: format!("ref:{reference} (cli override)"),
435 source: ResolvedImageSource::Reference(reference.clone()),
436 trust: classify_reference_trust(reference, None),
437 verify_signature: false,
438 });
439 }
440
441 if let Some(image) = profile_image {
442 if let Some(reference) = &image.reference {
443 let resolved_reference = attach_digest(reference, image.digest.as_deref());
444 return Ok(ResolvedImage {
445 description: format!("ref:{resolved_reference} (profile override)"),
446 source: ResolvedImageSource::Reference(resolved_reference.clone()),
447 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
448 verify_signature: image.verify_signature.unwrap_or(false),
449 });
450 }
451
452 if let Some(build) = &image.build {
453 let recipe_path = resolve_relative_path(build, workspace_root);
454 let tag = image.tag.clone().unwrap_or_else(|| {
455 format!(
456 "sbox-build-{}",
457 stable_hash(&recipe_path.display().to_string())
458 )
459 });
460
461 return Ok(ResolvedImage {
462 description: format!("build:{} (profile override)", recipe_path.display()),
463 source: ResolvedImageSource::Build { recipe_path, tag },
464 trust: ImageTrust::LocalBuild,
465 verify_signature: image.verify_signature.unwrap_or(false),
466 });
467 }
468
469 if let Some(preset) = &image.preset {
470 let reference = resolve_preset_reference(preset)?;
471 let resolved_reference = attach_digest(&reference, image.digest.as_deref());
472 return Ok(ResolvedImage {
473 description: format!(
474 "preset:{preset} -> ref:{resolved_reference} (profile override)"
475 ),
476 source: ResolvedImageSource::Reference(resolved_reference.clone()),
477 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
478 verify_signature: image.verify_signature.unwrap_or(false),
479 });
480 }
481 }
482
483 if let Some(reference) = &image.reference {
484 let resolved_reference = attach_digest(reference, image.digest.as_deref());
485 return Ok(ResolvedImage {
486 description: format!("ref:{resolved_reference}"),
487 source: ResolvedImageSource::Reference(resolved_reference.clone()),
488 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
489 verify_signature: image.verify_signature.unwrap_or(false),
490 });
491 }
492
493 if let Some(build) = &image.build {
494 let recipe_path = resolve_relative_path(build, workspace_root);
495 let tag = image.tag.clone().unwrap_or_else(|| {
496 format!(
497 "sbox-build-{}",
498 stable_hash(&recipe_path.display().to_string())
499 )
500 });
501
502 return Ok(ResolvedImage {
503 description: format!("build:{}", recipe_path.display()),
504 source: ResolvedImageSource::Build { recipe_path, tag },
505 trust: ImageTrust::LocalBuild,
506 verify_signature: image.verify_signature.unwrap_or(false),
507 });
508 }
509
510 if let Some(preset) = &image.preset {
511 let reference = resolve_preset_reference(preset)?;
512 let resolved_reference = attach_digest(&reference, image.digest.as_deref());
513 return Ok(ResolvedImage {
514 description: format!("preset:{preset} -> ref:{resolved_reference}"),
515 source: ResolvedImageSource::Reference(resolved_reference.clone()),
516 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
517 verify_signature: image.verify_signature.unwrap_or(false),
518 });
519 }
520
521 Err(SboxError::ConfigValidation {
522 message: "`image` must define exactly one of `ref`, `build`, or `preset`".to_string(),
523 })
524}
525
526fn resolve_workspace(loaded: &LoadedConfig, mount: &str) -> ResolvedWorkspace {
527 if let Ok(relative) = loaded.invocation_dir.strip_prefix(&loaded.workspace_root) {
528 let sandbox_cwd = join_sandbox_path(mount, relative);
529 ResolvedWorkspace {
530 root: loaded.workspace_root.clone(),
531 invocation_dir: loaded.invocation_dir.clone(),
532 effective_host_dir: loaded.invocation_dir.clone(),
533 mount: mount.to_string(),
534 sandbox_cwd,
535 cwd_mapping: CwdMapping::InvocationMapped,
536 }
537 } else {
538 ResolvedWorkspace {
539 root: loaded.workspace_root.clone(),
540 invocation_dir: loaded.invocation_dir.clone(),
541 effective_host_dir: loaded.workspace_root.clone(),
542 mount: mount.to_string(),
543 sandbox_cwd: mount.to_string(),
544 cwd_mapping: CwdMapping::WorkspaceRootFallback,
545 }
546 }
547}
548
549fn resolve_policy(
550 config: &Config,
551 profile_name: &str,
552 profile: &ProfileConfig,
553 mode: &ExecutionMode,
554 workspace_root: &Path,
555) -> ResolvedPolicy {
556 let (cap_drop, cap_add) = resolve_capabilities(profile);
557 let reuse_container = profile.reuse_container.unwrap_or_else(|| {
558 config
559 .runtime
560 .as_ref()
561 .and_then(|runtime| runtime.reuse_container)
562 .unwrap_or(false)
563 });
564
565 let pull_policy = profile
566 .image
567 .as_ref()
568 .and_then(|img| img.pull_policy.as_ref())
569 .or_else(|| {
570 config
571 .image
572 .as_ref()
573 .and_then(|img| img.pull_policy.as_ref())
574 })
575 .or_else(|| {
576 config
577 .runtime
578 .as_ref()
579 .and_then(|rt| rt.pull_policy.as_ref())
580 })
581 .map(pull_policy_flag);
582
583 let network_allow_resolved = resolve_network_allow(&profile.network_allow, &profile.network);
584
585 ResolvedPolicy {
586 network: profile.network.clone().unwrap_or_else(|| "off".to_string()),
587 writable: profile.writable.unwrap_or(true),
588 ports: if matches!(mode, ExecutionMode::Sandbox) {
589 profile.ports.clone()
590 } else {
591 Vec::new()
592 },
593 no_new_privileges: profile.no_new_privileges.unwrap_or(true),
594 read_only_rootfs: profile.read_only_rootfs.unwrap_or(false),
595 reuse_container,
596 reusable_session_name: reuse_container
597 .then(|| reusable_session_name(config, workspace_root, profile_name)),
598 cap_drop,
599 cap_add,
600 pull_policy,
601 network_allow: network_allow_resolved.0,
602 network_allow_patterns: network_allow_resolved.1,
603 }
604}
605
606fn pull_policy_flag(policy: &crate::config::model::PullPolicy) -> String {
607 match policy {
608 crate::config::model::PullPolicy::Always => "always".to_string(),
609 crate::config::model::PullPolicy::IfMissing => "missing".to_string(),
610 crate::config::model::PullPolicy::Never => "never".to_string(),
611 }
612}
613
614fn resolve_capabilities(profile: &ProfileConfig) -> (Vec<String>, Vec<String>) {
615 match &profile.capabilities {
616 Some(crate::config::model::CapabilitiesSpec::Structured(cfg)) => {
617 (cfg.drop.clone(), cfg.add.clone())
618 }
619 Some(crate::config::model::CapabilitiesSpec::Keyword(keyword)) if keyword == "drop-all" => {
620 (vec!["all".to_string()], Vec::new())
621 }
622 Some(crate::config::model::CapabilitiesSpec::List(values)) => (Vec::new(), values.clone()),
623 Some(crate::config::model::CapabilitiesSpec::Keyword(_)) => {
624 (Vec::new(), Vec::new())
626 }
627 None => (Vec::new(), Vec::new()),
628 }
629}
630
631fn resolve_network_allow(
641 domains: &[String],
642 network: &Option<String>,
643) -> (Vec<(String, String)>, Vec<String>) {
644 if domains.is_empty() {
645 return (Vec::new(), Vec::new());
646 }
647 if network.as_deref() == Some("off") {
648 return (Vec::new(), Vec::new());
649 }
650
651 let mut entries: Vec<(String, String)> = Vec::new();
652 let mut patterns: Vec<String> = Vec::new();
653
654 for entry in domains {
655 if let Some(base) = extract_pattern_base(entry) {
656 patterns.push(entry.clone());
658 for hostname in expand_pattern_hosts(&base) {
659 resolve_hostname_into(&hostname, &mut entries);
660 }
661 } else {
662 resolve_hostname_into(entry, &mut entries);
663 }
664 }
665
666 (entries, patterns)
667}
668
669fn expand_pattern_hosts(base: &str) -> Vec<String> {
675 const KNOWN: &[(&str, &[&str])] = &[
676 (
678 "npmjs.org",
679 &["registry.npmjs.org", "npmjs.org", "www.npmjs.org"],
680 ),
681 ("yarnpkg.com", &["registry.yarnpkg.com", "yarnpkg.com"]),
682 ("pypi.org", &["pypi.org", "files.pythonhosted.org"]),
684 (
685 "pythonhosted.org",
686 &["files.pythonhosted.org", "pythonhosted.org"],
687 ),
688 (
690 "crates.io",
691 &["crates.io", "static.crates.io", "index.crates.io"],
692 ),
693 (
695 "golang.org",
696 &["proxy.golang.org", "sum.golang.org", "golang.org"],
697 ),
698 ("go.dev", &["proxy.golang.dev", "sum.golang.dev", "go.dev"]),
699 (
701 "rubygems.org",
702 &["rubygems.org", "api.rubygems.org", "index.rubygems.org"],
703 ),
704 ("maven.org", &["repo1.maven.org", "central.maven.org"]),
706 (
707 "gradle.org",
708 &["plugins.gradle.org", "services.gradle.org", "gradle.org"],
709 ),
710 (
712 "github.com",
713 &[
714 "github.com",
715 "api.github.com",
716 "raw.githubusercontent.com",
717 "objects.githubusercontent.com",
718 "codeload.github.com",
719 ],
720 ),
721 (
722 "githubusercontent.com",
723 &[
724 "raw.githubusercontent.com",
725 "objects.githubusercontent.com",
726 "avatars.githubusercontent.com",
727 ],
728 ),
729 (
731 "docker.io",
732 &[
733 "registry-1.docker.io",
734 "auth.docker.io",
735 "production.cloudflare.docker.com",
736 ],
737 ),
738 ("ghcr.io", &["ghcr.io"]),
739 ("gcr.io", &["gcr.io"]),
740 ];
741
742 for (domain, subdomains) in KNOWN {
743 if base == *domain {
744 return subdomains.iter().map(|s| s.to_string()).collect();
745 }
746 }
747
748 vec![base.to_string()]
750}
751
752fn extract_pattern_base(entry: &str) -> Option<String> {
759 if let Some(rest) = entry.strip_prefix("*.") {
761 return Some(rest.to_string());
762 }
763 if let Some(rest) = entry.strip_prefix(".*\\.") {
765 return Some(rest.replace("\\.", "."));
766 }
767 if let Some(rest) = entry.strip_prefix('.')
769 && !rest.is_empty()
770 {
771 return Some(rest.to_string());
772 }
773 None
774}
775
776fn resolve_hostname_into(hostname: &str, entries: &mut Vec<(String, String)>) {
778 let addr = format!("{hostname}:443");
779 if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&addr.as_str()) {
780 for socket_addr in addrs {
781 let ip = socket_addr.ip().to_string();
782 if !entries.iter().any(|(h, a)| h == hostname && a == &ip) {
783 entries.push((hostname.to_string(), ip));
784 }
785 }
786 }
787}
788
789fn resolve_environment(config: &EnvironmentConfig) -> ResolvedEnvironment {
790 let denied: BTreeSet<&str> = config.deny.iter().map(String::as_str).collect();
791 let mut variables = BTreeMap::<String, ResolvedEnvVar>::new();
792
793 for name in &config.pass_through {
794 if denied.contains(name.as_str()) {
795 continue;
796 }
797
798 if let Ok(value) = std::env::var(name) {
799 variables.insert(
800 name.clone(),
801 ResolvedEnvVar {
802 name: name.clone(),
803 value,
804 source: EnvVarSource::PassThrough,
805 },
806 );
807 }
808 }
809
810 for (name, value) in &config.set {
811 if denied.contains(name.as_str()) {
812 continue;
813 }
814 variables.insert(
815 name.clone(),
816 ResolvedEnvVar {
817 name: name.clone(),
818 value: value.clone(),
819 source: EnvVarSource::Set,
820 },
821 );
822 }
823
824 ResolvedEnvironment {
825 variables: variables.into_values().collect(),
826 denied: config.deny.clone(),
827 }
828}
829
830fn resolved_sensitive_pass_through_vars(environment: &ResolvedEnvironment) -> Vec<String> {
831 environment
832 .variables
833 .iter()
834 .filter(|variable| {
835 matches!(variable.source, EnvVarSource::PassThrough)
836 && looks_like_sensitive_env(&variable.name)
837 })
838 .map(|variable| variable.name.clone())
839 .collect()
840}
841
842fn is_install_style(role: &Option<ProfileRole>, profile_name: &str) -> bool {
845 match role {
846 Some(ProfileRole::Install) => true,
847 Some(_) => false,
848 None => {
849 matches!(
850 profile_name,
851 "install" | "deps" | "dependency-install" | "bootstrap"
852 ) || profile_name.contains("install")
853 }
854 }
855}
856
857fn looks_like_sensitive_env(name: &str) -> bool {
858 const EXACT: &[&str] = &[
859 "SSH_AUTH_SOCK",
860 "GITHUB_TOKEN",
861 "GH_TOKEN",
862 "NPM_TOKEN",
863 "NODE_AUTH_TOKEN",
864 "PYPI_TOKEN",
865 "DOCKER_CONFIG",
866 "KUBECONFIG",
867 "GOOGLE_APPLICATION_CREDENTIALS",
868 "AZURE_CLIENT_SECRET",
869 "AWS_SESSION_TOKEN",
870 "AWS_SECRET_ACCESS_KEY",
871 "AWS_ACCESS_KEY_ID",
872 ];
873 const PREFIXES: &[&str] = &["AWS_", "GCP_", "GOOGLE_", "AZURE_", "CLOUDSDK_"];
874
875 EXACT.contains(&name) || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
876}
877
878fn collect_excluded_files(
882 workspace_root: &Path,
883 dir: &Path,
884 pattern: &str,
885 out: &mut Vec<PathBuf>,
886) {
887 let Ok(entries) = std::fs::read_dir(dir) else {
888 return;
889 };
890 for entry in entries.flatten() {
891 let file_type = match entry.file_type() {
892 Ok(ft) => ft,
893 Err(_) => continue,
894 };
895 if file_type.is_symlink() {
897 continue;
898 }
899 let path = entry.path();
900 if file_type.is_dir() {
901 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
902 if matches!(
904 name,
905 ".git"
906 | "node_modules"
907 | "target"
908 | ".venv"
909 | "__pycache__"
910 | "vendor"
911 | "dist"
912 | "build"
913 | ".cache"
914 | ".gradle"
915 | ".tox"
916 ) {
917 continue;
918 }
919 collect_excluded_files(workspace_root, &path, pattern, out);
920 } else if file_type.is_file()
921 && let Ok(rel) = path.strip_prefix(workspace_root)
922 {
923 let rel_str = rel.to_string_lossy();
924 if exclude_pattern_matches(&rel_str, pattern) {
925 out.push(path);
926 }
927 }
928 }
929}
930
931pub(crate) fn exclude_pattern_matches(relative_path: &str, pattern: &str) -> bool {
938 let effective = pattern.trim_start_matches("**/");
939 if effective.contains('/') {
940 glob_match(relative_path, effective)
942 } else {
943 let filename = relative_path.rsplit('/').next().unwrap_or(relative_path);
945 glob_match(filename, effective)
946 }
947}
948
949pub(crate) fn glob_match(s: &str, pattern: &str) -> bool {
951 if !pattern.contains('*') {
952 return s == pattern;
953 }
954 let parts: Vec<&str> = pattern.split('*').collect();
955 let mut remaining = s;
956 for (i, part) in parts.iter().enumerate() {
957 if part.is_empty() {
958 continue;
959 }
960 if i == 0 {
961 if !remaining.starts_with(part) {
962 return false;
963 }
964 remaining = &remaining[part.len()..];
965 } else if i == parts.len() - 1 {
966 return remaining.ends_with(part);
967 } else {
968 match remaining.find(part) {
969 Some(pos) => remaining = &remaining[pos + part.len()..],
970 None => return false,
971 }
972 }
973 }
974 true
975}
976
977fn resolve_mounts(
978 config: &Config,
979 profile: &ProfileConfig,
980 workspace_root: &Path,
981 workspace_mount: &str,
982 profile_writable: bool,
983) -> Vec<ResolvedMount> {
984 let workspace_writable = config
985 .workspace
986 .as_ref()
987 .and_then(|workspace| workspace.writable)
988 .unwrap_or(true)
989 && profile_writable;
990
991 let mut mounts = vec![ResolvedMount {
992 kind: "bind".to_string(),
993 source: Some(workspace_root.to_path_buf()),
994 target: workspace_mount.to_string(),
995 read_only: !workspace_writable,
996 is_workspace: true,
997 create: false,
998 }];
999
1000 if !workspace_writable {
1004 let writable_paths: &[String] = profile.writable_paths.as_deref().unwrap_or_else(|| {
1005 config
1006 .workspace
1007 .as_ref()
1008 .map(|ws| ws.writable_paths.as_slice())
1009 .unwrap_or(&[])
1010 });
1011 for rel_path in writable_paths {
1012 mounts.push(ResolvedMount {
1013 kind: "bind".to_string(),
1014 source: Some(workspace_root.join(rel_path)),
1015 target: format!("{workspace_mount}/{rel_path}"),
1016 read_only: false,
1017 is_workspace: true,
1018 create: true,
1019 });
1020 }
1021 }
1022
1023 for mount in &config.mounts {
1024 let source = match mount.mount_type {
1025 MountType::Bind => mount
1026 .source
1027 .as_deref()
1028 .map(|path| resolve_relative_path(path, workspace_root)),
1029 MountType::Tmpfs => None,
1030 };
1031
1032 mounts.push(ResolvedMount {
1033 kind: match mount.mount_type {
1034 MountType::Bind => "bind".to_string(),
1035 MountType::Tmpfs => "tmpfs".to_string(),
1036 },
1037 source,
1038 target: mount.target.clone().expect("validated mount target"),
1039 read_only: mount.read_only.unwrap_or(false),
1040 is_workspace: false,
1041 create: false,
1042 });
1043 }
1044
1045 let exclude_patterns = config
1048 .workspace
1049 .as_ref()
1050 .map(|ws| ws.exclude_paths.as_slice())
1051 .unwrap_or(&[]);
1052 for pattern in exclude_patterns {
1053 let mut matched = Vec::new();
1054 collect_excluded_files(workspace_root, workspace_root, pattern, &mut matched);
1055 for host_path in matched {
1056 if let Ok(rel) = host_path.strip_prefix(workspace_root) {
1057 let target = format!("{workspace_mount}/{}", rel.display());
1058 mounts.push(ResolvedMount {
1059 kind: "mask".to_string(),
1060 source: None,
1061 target,
1062 read_only: true,
1063 is_workspace: true,
1064 create: false,
1065 });
1066 }
1067 }
1068 }
1069
1070 mounts
1071}
1072
1073fn resolve_caches(caches: &[CacheConfig]) -> Vec<ResolvedCache> {
1074 caches
1075 .iter()
1076 .map(|cache| ResolvedCache {
1077 name: cache.name.clone(),
1078 target: cache.target.clone(),
1079 source: cache.source.clone(),
1080 read_only: cache.read_only.unwrap_or(false),
1081 })
1082 .collect()
1083}
1084
1085fn resolve_secrets(
1086 secrets: &[SecretConfig],
1087 active_profile: &str,
1088 active_role: Option<&ProfileRole>,
1089) -> Vec<ResolvedSecret> {
1090 secrets
1091 .iter()
1092 .filter(|secret| {
1093 let profile_ok = secret.when_profiles.is_empty()
1095 || secret.when_profiles.iter().any(|p| p == active_profile);
1096
1097 let role_ok = active_role
1099 .map(|role| !secret.deny_roles.contains(role))
1100 .unwrap_or(true);
1101
1102 profile_ok && role_ok
1103 })
1104 .map(|secret| ResolvedSecret {
1105 name: secret.name.clone(),
1106 source: secret.source.clone(),
1107 target: secret.target.clone(),
1108 })
1109 .collect()
1110}
1111
1112fn resolve_user(config: &Config, rootless: bool) -> ResolvedUser {
1113 match config.identity.as_ref() {
1114 Some(identity) => match (identity.uid, identity.gid) {
1115 (Some(uid), Some(gid)) => ResolvedUser::Explicit { uid, gid },
1116 _ if identity.map_user.unwrap_or(rootless) => ResolvedUser::KeepId,
1117 _ => ResolvedUser::Default,
1118 },
1119 None if rootless => ResolvedUser::KeepId,
1120 None => ResolvedUser::Default,
1121 }
1122}
1123
1124fn resolve_relative_path(path: &Path, base: &Path) -> PathBuf {
1125 if path.is_absolute() {
1126 path.to_path_buf()
1127 } else {
1128 base.join(path)
1129 }
1130}
1131
1132fn join_sandbox_path(mount: &str, relative: &Path) -> String {
1133 let mut path = mount.trim_end_matches('/').to_string();
1134 if path.is_empty() {
1135 path.push('/');
1136 }
1137
1138 for component in relative.components() {
1139 let segment = component.as_os_str().to_string_lossy();
1140 if segment.is_empty() || segment == "." {
1141 continue;
1142 }
1143
1144 if !path.ends_with('/') {
1145 path.push('/');
1146 }
1147 path.push_str(&segment);
1148 }
1149
1150 path
1151}
1152
1153fn stable_hash(input: &str) -> String {
1154 let mut hash = 0xcbf29ce484222325u64;
1155 for byte in input.as_bytes() {
1156 hash ^= u64::from(*byte);
1157 hash = hash.wrapping_mul(0x100000001b3);
1158 }
1159 format!("{hash:016x}")
1160}
1161
1162fn reusable_session_name(config: &Config, workspace_root: &Path, profile_name: &str) -> String {
1163 if let Some(template) = config
1164 .runtime
1165 .as_ref()
1166 .and_then(|runtime| runtime.container_name.as_ref())
1167 {
1168 let workspace_hash = stable_hash(&workspace_root.display().to_string());
1169 return sanitize_session_name(
1170 &template
1171 .replace("{profile}", profile_name)
1172 .replace("{workspace_hash}", &workspace_hash),
1173 );
1174 }
1175
1176 sanitize_session_name(&format!(
1177 "sbox-{}-{}",
1178 stable_hash(&workspace_root.display().to_string()),
1179 profile_name
1180 ))
1181}
1182
1183fn sanitize_session_name(name: &str) -> String {
1184 name.chars()
1185 .map(|ch| {
1186 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
1187 ch
1188 } else {
1189 '-'
1190 }
1191 })
1192 .collect()
1193}
1194
1195fn resolve_preset_reference(preset: &str) -> Result<String, SboxError> {
1196 let reference = match preset {
1197 "python" => "python:3.13-slim",
1198 "node" => "node:22-bookworm-slim",
1199 "rust" => "rust:1-bookworm",
1200 "go" => "golang:1.24-bookworm",
1201 "java" => "eclipse-temurin:21-jdk",
1202 "php" => "php:8.3-cli-bookworm",
1203 "polyglot" => "ubuntu:24.04",
1204 _ => {
1205 return Err(SboxError::UnknownPreset {
1206 name: preset.to_string(),
1207 });
1208 }
1209 };
1210
1211 Ok(reference.to_string())
1212}
1213
1214fn attach_digest(reference: &str, digest: Option<&str>) -> String {
1215 match digest {
1216 Some(digest) if !reference.contains('@') => format!("{reference}@{digest}"),
1217 _ => reference.to_string(),
1218 }
1219}
1220
1221fn classify_reference_trust(reference: &str, digest: Option<&str>) -> ImageTrust {
1222 if digest.is_some() || reference.contains("@sha256:") {
1223 ImageTrust::PinnedDigest
1224 } else {
1225 ImageTrust::MutableReference
1226 }
1227}
1228
1229fn resolve_lockfile_audit(
1231 lockfile_files: &[String],
1232 install_style: bool,
1233 project_dir: &Path,
1234 require_lockfile: Option<bool>,
1235) -> LockfileAudit {
1236 if !install_style || lockfile_files.is_empty() {
1237 return LockfileAudit {
1238 applicable: false,
1239 required: require_lockfile.unwrap_or(false),
1240 present: false,
1241 expected_files: Vec::new(),
1242 };
1243 }
1244
1245 let present = lockfile_files
1246 .iter()
1247 .any(|candidate| project_dir.join(candidate).exists());
1248
1249 LockfileAudit {
1250 applicable: true,
1251 required: require_lockfile.unwrap_or(true),
1252 present,
1253 expected_files: lockfile_files.to_vec(),
1254 }
1255}
1256
1257fn parse_pre_run_commands(pre_run: &[String]) -> Vec<Vec<String>> {
1260 pre_run
1261 .iter()
1262 .filter_map(|s| {
1263 let tokens: Vec<String> = s.split_whitespace().map(str::to_string).collect();
1264 if tokens.is_empty() {
1265 None
1266 } else {
1267 Some(tokens)
1268 }
1269 })
1270 .collect()
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use indexmap::IndexMap;
1276
1277 use super::{
1278 ImageTrust, ProfileSource, ResolutionTarget, ResolvedImageSource, ResolvedUser,
1279 resolve_execution_plan,
1280 };
1281 use crate::cli::{Cli, Commands, PlanCommand};
1282 use crate::config::{
1283 BackendKind,
1284 load::LoadedConfig,
1285 model::{
1286 Config, DispatchRule, ExecutionMode, ImageConfig, ProfileConfig, ProfileRole,
1287 RuntimeConfig, WorkspaceConfig,
1288 },
1289 };
1290
1291 fn base_cli() -> Cli {
1292 Cli {
1293 config: None,
1294 workspace: None,
1295 backend: None,
1296 image: None,
1297 profile: None,
1298 mode: None,
1299 strict_security: false,
1300 verbose: 0,
1301 quiet: false,
1302 command: Commands::Plan(PlanCommand {
1303 show_command: false,
1304 command: vec!["npm".into(), "install".into()],
1305 }),
1306 }
1307 }
1308
1309 fn base_config() -> Config {
1310 let mut profiles = IndexMap::new();
1311 profiles.insert(
1312 "default".to_string(),
1313 ProfileConfig {
1314 mode: ExecutionMode::Sandbox,
1315 image: None,
1316 network: Some("off".to_string()),
1317 writable: Some(true),
1318 require_pinned_image: None,
1319 require_lockfile: None,
1320 role: None,
1321 lockfile_files: Vec::new(),
1322 pre_run: Vec::new(),
1323 network_allow: Vec::new(),
1324 ports: Vec::new(),
1325 capabilities: None,
1326 no_new_privileges: Some(true),
1327 read_only_rootfs: None,
1328 reuse_container: None,
1329 shell: None,
1330
1331 writable_paths: None,
1332 },
1333 );
1334 profiles.insert(
1335 "install".to_string(),
1336 ProfileConfig {
1337 mode: ExecutionMode::Sandbox,
1338 image: None,
1339 network: Some("on".to_string()),
1340 writable: Some(true),
1341 require_pinned_image: None,
1342 require_lockfile: None,
1343 role: Some(ProfileRole::Install),
1344 lockfile_files: Vec::new(),
1345 pre_run: Vec::new(),
1346 network_allow: Vec::new(),
1347 ports: Vec::new(),
1348 capabilities: None,
1349 no_new_privileges: Some(true),
1350 read_only_rootfs: None,
1351 reuse_container: None,
1352 shell: None,
1353
1354 writable_paths: None,
1355 },
1356 );
1357
1358 let mut dispatch = IndexMap::new();
1359 dispatch.insert(
1360 "install".to_string(),
1361 DispatchRule {
1362 patterns: vec!["npm install".to_string()],
1363 profile: "install".to_string(),
1364 },
1365 );
1366
1367 Config {
1368 version: 1,
1369 runtime: Some(RuntimeConfig {
1370 backend: Some(BackendKind::Podman),
1371 rootless: Some(true),
1372 reuse_container: Some(false),
1373 container_name: None,
1374 pull_policy: None,
1375 strict_security: None,
1376 require_pinned_image: None,
1377 }),
1378 workspace: Some(WorkspaceConfig {
1379 root: None,
1380 mount: Some("/workspace".to_string()),
1381 writable: Some(true),
1382 writable_paths: Vec::new(),
1383 exclude_paths: Vec::new(),
1384 }),
1385 identity: None,
1386 image: Some(ImageConfig {
1387 reference: Some("python:3.13-slim".to_string()),
1388 build: None,
1389 preset: None,
1390 digest: None,
1391 verify_signature: None,
1392 pull_policy: None,
1393 tag: None,
1394 }),
1395 environment: None,
1396 mounts: Vec::new(),
1397 caches: Vec::new(),
1398 secrets: Vec::new(),
1399 profiles,
1400 dispatch,
1401
1402 package_manager: None,
1403 }
1404 }
1405
1406 fn loaded_config(config: Config) -> LoadedConfig {
1407 LoadedConfig {
1408 invocation_dir: PathBuf::from("/workspace/project"),
1409 workspace_root: PathBuf::from("/workspace/project"),
1410 config_path: PathBuf::from("/workspace/project/sbox.yaml"),
1411 config,
1412 }
1413 }
1414
1415 use std::path::PathBuf;
1416
1417 #[test]
1418 fn selects_dispatch_profile_in_declaration_order() {
1419 let cli = base_cli();
1420 let plan = resolve_execution_plan(
1421 &cli,
1422 &loaded_config(base_config()),
1423 ResolutionTarget::Plan,
1424 &["npm".into(), "install".into()],
1425 )
1426 .expect("resolution should succeed");
1427
1428 assert_eq!(plan.profile_name, "install");
1429 assert!(matches!(
1430 plan.image.source,
1431 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1432 ));
1433 assert_eq!(plan.image.trust, ImageTrust::MutableReference);
1434 assert!(matches!(plan.user, ResolvedUser::KeepId));
1435 match plan.profile_source {
1436 ProfileSource::Dispatch { rule_name, pattern } => {
1437 assert_eq!(rule_name, "install");
1438 assert_eq!(pattern, "npm install");
1439 }
1440 other => panic!("expected dispatch source, got {other:?}"),
1441 }
1442 }
1443
1444 #[test]
1445 fn falls_back_to_default_profile_when_no_dispatch_matches() {
1446 let cli = base_cli();
1447 let plan = resolve_execution_plan(
1448 &cli,
1449 &loaded_config(base_config()),
1450 ResolutionTarget::Plan,
1451 &["echo".into(), "hello".into()],
1452 )
1453 .expect("resolution should succeed");
1454
1455 assert_eq!(plan.profile_name, "default");
1456 assert!(matches!(plan.profile_source, ProfileSource::DefaultProfile));
1457 assert_eq!(plan.policy.cap_drop, Vec::<String>::new());
1458 }
1459
1460 #[test]
1461 fn workspace_mount_becomes_read_only_when_profile_is_not_writable() {
1462 let cli = base_cli();
1463 let mut config = base_config();
1464 config
1465 .profiles
1466 .get_mut("default")
1467 .expect("default profile exists")
1468 .writable = Some(false);
1469
1470 let plan = resolve_execution_plan(
1471 &cli,
1472 &loaded_config(config),
1473 ResolutionTarget::Plan,
1474 &["echo".into(), "hello".into()],
1475 )
1476 .expect("resolution should succeed");
1477
1478 let workspace_mount = plan
1479 .mounts
1480 .iter()
1481 .find(|mount| mount.is_workspace)
1482 .expect("workspace mount should be present");
1483
1484 assert!(workspace_mount.read_only);
1485 assert!(!plan.policy.writable);
1486 }
1487
1488 #[test]
1489 fn runtime_reuse_container_enables_reusable_session_name() {
1490 let cli = base_cli();
1491 let mut config = base_config();
1492 config
1493 .runtime
1494 .as_mut()
1495 .expect("runtime exists")
1496 .reuse_container = Some(true);
1497
1498 let plan = resolve_execution_plan(
1499 &cli,
1500 &loaded_config(config),
1501 ResolutionTarget::Plan,
1502 &["echo".into(), "hello".into()],
1503 )
1504 .expect("resolution should succeed");
1505
1506 assert!(plan.policy.reuse_container);
1507 assert!(
1508 plan.policy
1509 .reusable_session_name
1510 .as_deref()
1511 .is_some_and(|name| name.starts_with("sbox-"))
1512 );
1513 }
1514
1515 #[test]
1516 fn install_role_marks_install_style() {
1517 let cli = base_cli();
1518 let plan = resolve_execution_plan(
1519 &cli,
1520 &loaded_config(base_config()),
1521 ResolutionTarget::Plan,
1522 &["npm".into(), "install".into()],
1523 )
1524 .expect("resolution should succeed");
1525
1526 assert!(plan.audit.install_style);
1528 assert!(!plan.audit.trusted_image_required);
1529 }
1530
1531 #[test]
1532 fn resolves_known_presets_to_references() {
1533 let cli = base_cli();
1534 let mut config = base_config();
1535 config.image = Some(ImageConfig {
1536 reference: None,
1537 build: None,
1538 preset: Some("python".to_string()),
1539 digest: None,
1540 verify_signature: None,
1541 pull_policy: None,
1542 tag: None,
1543 });
1544
1545 let plan = resolve_execution_plan(
1546 &cli,
1547 &loaded_config(config),
1548 ResolutionTarget::Plan,
1549 &["python".into(), "--version".into()],
1550 )
1551 .expect("resolution should succeed");
1552
1553 assert!(matches!(
1554 plan.image.source,
1555 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1556 ));
1557 }
1558
1559 #[test]
1560 fn profile_can_require_trusted_image() {
1561 let cli = base_cli();
1562 let mut config = base_config();
1563 config
1564 .profiles
1565 .get_mut("install")
1566 .expect("install profile exists")
1567 .require_pinned_image = Some(true);
1568
1569 let plan = resolve_execution_plan(
1570 &cli,
1571 &loaded_config(config),
1572 ResolutionTarget::Plan,
1573 &["npm".into(), "install".into()],
1574 )
1575 .expect("resolution should succeed");
1576
1577 assert!(plan.audit.install_style);
1578 assert!(plan.audit.trusted_image_required);
1579 }
1580
1581 #[test]
1582 fn image_digest_pins_reference_trust() {
1583 let cli = base_cli();
1584 let mut config = base_config();
1585 config.image = Some(ImageConfig {
1586 reference: Some("python:3.13-slim".to_string()),
1587 build: None,
1588 preset: None,
1589 digest: Some("sha256:deadbeef".to_string()),
1590 verify_signature: Some(true),
1591 pull_policy: None,
1592 tag: None,
1593 });
1594
1595 let plan = resolve_execution_plan(
1596 &cli,
1597 &loaded_config(config),
1598 ResolutionTarget::Plan,
1599 &["python".into(), "--version".into()],
1600 )
1601 .expect("resolution should succeed");
1602
1603 assert!(matches!(
1604 plan.image.source,
1605 ResolvedImageSource::Reference(ref image)
1606 if image == "python:3.13-slim@sha256:deadbeef"
1607 ));
1608 assert_eq!(plan.image.trust, ImageTrust::PinnedDigest);
1609 assert!(plan.image.verify_signature);
1610 }
1611
1612 #[test]
1613 fn profile_lockfile_files_drive_lockfile_audit() {
1614 let cli = base_cli();
1615 let mut config = base_config();
1616 let profile = config
1617 .profiles
1618 .get_mut("install")
1619 .expect("install profile exists");
1620 profile.require_lockfile = Some(true);
1621 profile.lockfile_files = vec![
1622 "package-lock.json".to_string(),
1623 "npm-shrinkwrap.json".to_string(),
1624 ];
1625
1626 let plan = resolve_execution_plan(
1627 &cli,
1628 &loaded_config(config),
1629 ResolutionTarget::Plan,
1630 &["npm".into(), "install".into()],
1631 )
1632 .expect("resolution should succeed");
1633
1634 assert!(plan.audit.lockfile.applicable);
1635 assert!(plan.audit.lockfile.required);
1636 assert_eq!(
1637 plan.audit.lockfile.expected_files,
1638 vec!["package-lock.json", "npm-shrinkwrap.json"]
1639 );
1640 }
1641
1642 #[test]
1643 fn pre_run_parses_into_argv_vecs() {
1644 let cli = base_cli();
1645 let mut config = base_config();
1646 config
1647 .profiles
1648 .get_mut("install")
1649 .expect("install profile exists")
1650 .pre_run = vec![
1651 "npm audit --audit-level=high".to_string(),
1652 "echo done".to_string(),
1653 ];
1654
1655 let plan = resolve_execution_plan(
1656 &cli,
1657 &loaded_config(config),
1658 ResolutionTarget::Plan,
1659 &["npm".into(), "install".into()],
1660 )
1661 .expect("resolution should succeed");
1662
1663 assert_eq!(plan.audit.pre_run.len(), 2);
1664 assert_eq!(
1665 plan.audit.pre_run[0],
1666 vec!["npm", "audit", "--audit-level=high"]
1667 );
1668 assert_eq!(plan.audit.pre_run[1], vec!["echo", "done"]);
1669 }
1670
1671 #[test]
1672 fn no_role_profile_name_heuristic_still_marks_install_style() {
1673 let cli = base_cli();
1674 let mut config = base_config();
1675 config.profiles.insert(
1677 "deps".to_string(),
1678 ProfileConfig {
1679 mode: ExecutionMode::Sandbox,
1680 image: None,
1681 network: Some("on".to_string()),
1682 writable: Some(true),
1683 require_pinned_image: None,
1684 require_lockfile: None,
1685 role: None,
1686 lockfile_files: Vec::new(),
1687 pre_run: Vec::new(),
1688 network_allow: Vec::new(),
1689 ports: Vec::new(),
1690 capabilities: None,
1691 no_new_privileges: Some(true),
1692 read_only_rootfs: None,
1693 reuse_container: None,
1694 shell: None,
1695
1696 writable_paths: None,
1697 },
1698 );
1699 config.dispatch.insert(
1700 "uv-sync".to_string(),
1701 crate::config::model::DispatchRule {
1702 patterns: vec!["uv sync".to_string()],
1703 profile: "deps".to_string(),
1704 },
1705 );
1706
1707 let plan = resolve_execution_plan(
1708 &cli,
1709 &loaded_config(config),
1710 ResolutionTarget::Plan,
1711 &["uv".into(), "sync".into()],
1712 )
1713 .expect("resolution should succeed");
1714
1715 assert_eq!(plan.profile_name, "deps");
1716 assert!(plan.audit.install_style); }
1718
1719 #[test]
1720 fn glob_match_exact_and_prefix_suffix_wildcards() {
1721 use super::glob_match;
1722
1723 assert!(glob_match("file.pem", "file.pem"));
1725 assert!(!glob_match("file.pem", "other.pem"));
1726
1727 assert!(glob_match("file.pem", "*.pem"));
1729 assert!(glob_match(".pem", "*.pem"));
1730 assert!(!glob_match("pem", "*.pem"));
1731 assert!(!glob_match("file.pem.bak", "*.pem"));
1732
1733 assert!(glob_match(".env.local", ".env.*"));
1735 assert!(glob_match(".env.production", ".env.*"));
1736 assert!(!glob_match(".env", ".env.*"));
1737
1738 assert!(glob_match("secrets", "secrets*"));
1740 assert!(glob_match("secrets.json", "secrets*"));
1741 assert!(!glob_match("not-secrets", "secrets*"));
1742
1743 assert!(glob_match("my.key.bak", "*.key.*"));
1745 assert!(glob_match("a.key.b", "*.key.*"));
1746 assert!(!glob_match("key.bak", "*.key.*"));
1747 assert!(!glob_match("my.key", "*.key.*"));
1748
1749 assert!(glob_match("abc", "a*b*c"));
1751 assert!(glob_match("aXbYc", "a*b*c"));
1752 assert!(glob_match("abbc", "a*b*c"));
1753 assert!(!glob_match("ac", "a*b*c"));
1754 assert!(!glob_match("aXbYd", "a*b*c"));
1755
1756 assert!(glob_match("XaYbZ", "*a*b*"));
1758 assert!(glob_match("ab", "*a*b*"));
1759 assert!(!glob_match("ba", "*a*b*"));
1760 assert!(!glob_match("XaZ", "*a*b*"));
1761 }
1762
1763 #[test]
1764 fn glob_match_path_patterns() {
1765 use super::exclude_pattern_matches;
1766
1767 assert!(exclude_pattern_matches("dir/file.pem", "*.pem"));
1769 assert!(exclude_pattern_matches("deep/nested/file.key", "*.key"));
1770 assert!(!exclude_pattern_matches("dir/file.pem.bak", "*.pem"));
1771
1772 assert!(exclude_pattern_matches("dir/file.pem", "**/*.pem"));
1774 assert!(exclude_pattern_matches("a/b/c.key", "**/*.key"));
1775
1776 assert!(exclude_pattern_matches("secrets/prod.json", "secrets/*"));
1778 assert!(!exclude_pattern_matches("other/prod.json", "secrets/*"));
1779 assert!(exclude_pattern_matches(
1780 "config/.env.local",
1781 "config/.env.*"
1782 ));
1783 }
1784}