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 std::env::var_os("PATH")
395 .map(|path_os| {
396 std::env::split_paths(&path_os).any(|dir| {
397 let candidate = dir.join(name);
398 candidate.is_file() && {
399 use std::os::unix::fs::PermissionsExt;
400 candidate
401 .metadata()
402 .map(|m| m.permissions().mode() & 0o111 != 0)
403 .unwrap_or(false)
404 }
405 })
406 })
407 .unwrap_or(false)
408}
409
410fn resolve_image(
411 cli: &Cli,
412 image: &ImageConfig,
413 profile_image: Option<&ImageConfig>,
414 workspace_root: &Path,
415) -> Result<ResolvedImage, SboxError> {
416 if let Some(reference) = &cli.image {
417 return Ok(ResolvedImage {
418 description: format!("ref:{reference} (cli override)"),
419 source: ResolvedImageSource::Reference(reference.clone()),
420 trust: classify_reference_trust(reference, None),
421 verify_signature: false,
422 });
423 }
424
425 if let Some(image) = profile_image {
426 if let Some(reference) = &image.reference {
427 let resolved_reference = attach_digest(reference, image.digest.as_deref());
428 return Ok(ResolvedImage {
429 description: format!("ref:{resolved_reference} (profile override)"),
430 source: ResolvedImageSource::Reference(resolved_reference.clone()),
431 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
432 verify_signature: image.verify_signature.unwrap_or(false),
433 });
434 }
435
436 if let Some(build) = &image.build {
437 let recipe_path = resolve_relative_path(build, workspace_root);
438 let tag = image.tag.clone().unwrap_or_else(|| {
439 format!(
440 "sbox-build-{}",
441 stable_hash(&recipe_path.display().to_string())
442 )
443 });
444
445 return Ok(ResolvedImage {
446 description: format!("build:{} (profile override)", recipe_path.display()),
447 source: ResolvedImageSource::Build { recipe_path, tag },
448 trust: ImageTrust::LocalBuild,
449 verify_signature: image.verify_signature.unwrap_or(false),
450 });
451 }
452
453 if let Some(preset) = &image.preset {
454 let reference = resolve_preset_reference(preset)?;
455 let resolved_reference = attach_digest(&reference, image.digest.as_deref());
456 return Ok(ResolvedImage {
457 description: format!(
458 "preset:{preset} -> ref:{resolved_reference} (profile override)"
459 ),
460 source: ResolvedImageSource::Reference(resolved_reference.clone()),
461 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
462 verify_signature: image.verify_signature.unwrap_or(false),
463 });
464 }
465 }
466
467 if let Some(reference) = &image.reference {
468 let resolved_reference = attach_digest(reference, image.digest.as_deref());
469 return Ok(ResolvedImage {
470 description: format!("ref:{resolved_reference}"),
471 source: ResolvedImageSource::Reference(resolved_reference.clone()),
472 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
473 verify_signature: image.verify_signature.unwrap_or(false),
474 });
475 }
476
477 if let Some(build) = &image.build {
478 let recipe_path = resolve_relative_path(build, workspace_root);
479 let tag = image.tag.clone().unwrap_or_else(|| {
480 format!(
481 "sbox-build-{}",
482 stable_hash(&recipe_path.display().to_string())
483 )
484 });
485
486 return Ok(ResolvedImage {
487 description: format!("build:{}", recipe_path.display()),
488 source: ResolvedImageSource::Build { recipe_path, tag },
489 trust: ImageTrust::LocalBuild,
490 verify_signature: image.verify_signature.unwrap_or(false),
491 });
492 }
493
494 if let Some(preset) = &image.preset {
495 let reference = resolve_preset_reference(preset)?;
496 let resolved_reference = attach_digest(&reference, image.digest.as_deref());
497 return Ok(ResolvedImage {
498 description: format!("preset:{preset} -> ref:{resolved_reference}"),
499 source: ResolvedImageSource::Reference(resolved_reference.clone()),
500 trust: classify_reference_trust(&resolved_reference, image.digest.as_deref()),
501 verify_signature: image.verify_signature.unwrap_or(false),
502 });
503 }
504
505 Err(SboxError::ConfigValidation {
506 message: "`image` must define exactly one of `ref`, `build`, or `preset`".to_string(),
507 })
508}
509
510fn resolve_workspace(loaded: &LoadedConfig, mount: &str) -> ResolvedWorkspace {
511 if let Ok(relative) = loaded.invocation_dir.strip_prefix(&loaded.workspace_root) {
512 let sandbox_cwd = join_sandbox_path(mount, relative);
513 ResolvedWorkspace {
514 root: loaded.workspace_root.clone(),
515 invocation_dir: loaded.invocation_dir.clone(),
516 effective_host_dir: loaded.invocation_dir.clone(),
517 mount: mount.to_string(),
518 sandbox_cwd,
519 cwd_mapping: CwdMapping::InvocationMapped,
520 }
521 } else {
522 ResolvedWorkspace {
523 root: loaded.workspace_root.clone(),
524 invocation_dir: loaded.invocation_dir.clone(),
525 effective_host_dir: loaded.workspace_root.clone(),
526 mount: mount.to_string(),
527 sandbox_cwd: mount.to_string(),
528 cwd_mapping: CwdMapping::WorkspaceRootFallback,
529 }
530 }
531}
532
533fn resolve_policy(
534 config: &Config,
535 profile_name: &str,
536 profile: &ProfileConfig,
537 mode: &ExecutionMode,
538 workspace_root: &Path,
539) -> ResolvedPolicy {
540 let (cap_drop, cap_add) = resolve_capabilities(profile);
541 let reuse_container = profile.reuse_container.unwrap_or_else(|| {
542 config
543 .runtime
544 .as_ref()
545 .and_then(|runtime| runtime.reuse_container)
546 .unwrap_or(false)
547 });
548
549 let pull_policy = profile
550 .image
551 .as_ref()
552 .and_then(|img| img.pull_policy.as_ref())
553 .or_else(|| {
554 config
555 .image
556 .as_ref()
557 .and_then(|img| img.pull_policy.as_ref())
558 })
559 .or_else(|| {
560 config
561 .runtime
562 .as_ref()
563 .and_then(|rt| rt.pull_policy.as_ref())
564 })
565 .map(pull_policy_flag);
566
567 let network_allow_resolved = resolve_network_allow(&profile.network_allow, &profile.network);
568
569 ResolvedPolicy {
570 network: profile.network.clone().unwrap_or_else(|| "off".to_string()),
571 writable: profile.writable.unwrap_or(true),
572 ports: if matches!(mode, ExecutionMode::Sandbox) {
573 profile.ports.clone()
574 } else {
575 Vec::new()
576 },
577 no_new_privileges: profile.no_new_privileges.unwrap_or(true),
578 read_only_rootfs: profile.read_only_rootfs.unwrap_or(false),
579 reuse_container,
580 reusable_session_name: reuse_container
581 .then(|| reusable_session_name(config, workspace_root, profile_name)),
582 cap_drop,
583 cap_add,
584 pull_policy,
585 network_allow: network_allow_resolved.0,
586 network_allow_patterns: network_allow_resolved.1,
587 }
588}
589
590fn pull_policy_flag(policy: &crate::config::model::PullPolicy) -> String {
591 match policy {
592 crate::config::model::PullPolicy::Always => "always".to_string(),
593 crate::config::model::PullPolicy::IfMissing => "missing".to_string(),
594 crate::config::model::PullPolicy::Never => "never".to_string(),
595 }
596}
597
598fn resolve_capabilities(profile: &ProfileConfig) -> (Vec<String>, Vec<String>) {
599 match &profile.capabilities {
600 Some(crate::config::model::CapabilitiesSpec::Structured(cfg)) => {
601 (cfg.drop.clone(), cfg.add.clone())
602 }
603 Some(crate::config::model::CapabilitiesSpec::Keyword(keyword)) if keyword == "drop-all" => {
604 (vec!["all".to_string()], Vec::new())
605 }
606 Some(crate::config::model::CapabilitiesSpec::List(values)) => (Vec::new(), values.clone()),
607 Some(crate::config::model::CapabilitiesSpec::Keyword(_)) => {
608 (Vec::new(), Vec::new())
610 }
611 None => (Vec::new(), Vec::new()),
612 }
613}
614
615fn resolve_network_allow(
625 domains: &[String],
626 network: &Option<String>,
627) -> (Vec<(String, String)>, Vec<String>) {
628 if domains.is_empty() {
629 return (Vec::new(), Vec::new());
630 }
631 if network.as_deref() == Some("off") {
632 return (Vec::new(), Vec::new());
633 }
634
635 let mut entries: Vec<(String, String)> = Vec::new();
636 let mut patterns: Vec<String> = Vec::new();
637
638 for entry in domains {
639 if let Some(base) = extract_pattern_base(entry) {
640 patterns.push(entry.clone());
642 for hostname in expand_pattern_hosts(&base) {
643 resolve_hostname_into(&hostname, &mut entries);
644 }
645 } else {
646 resolve_hostname_into(entry, &mut entries);
647 }
648 }
649
650 (entries, patterns)
651}
652
653fn expand_pattern_hosts(base: &str) -> Vec<String> {
659 const KNOWN: &[(&str, &[&str])] = &[
660 (
662 "npmjs.org",
663 &["registry.npmjs.org", "npmjs.org", "www.npmjs.org"],
664 ),
665 ("yarnpkg.com", &["registry.yarnpkg.com", "yarnpkg.com"]),
666 ("pypi.org", &["pypi.org", "files.pythonhosted.org"]),
668 (
669 "pythonhosted.org",
670 &["files.pythonhosted.org", "pythonhosted.org"],
671 ),
672 (
674 "crates.io",
675 &["crates.io", "static.crates.io", "index.crates.io"],
676 ),
677 (
679 "golang.org",
680 &["proxy.golang.org", "sum.golang.org", "golang.org"],
681 ),
682 ("go.dev", &["proxy.golang.dev", "sum.golang.dev", "go.dev"]),
683 (
685 "rubygems.org",
686 &["rubygems.org", "api.rubygems.org", "index.rubygems.org"],
687 ),
688 ("maven.org", &["repo1.maven.org", "central.maven.org"]),
690 (
691 "gradle.org",
692 &["plugins.gradle.org", "services.gradle.org", "gradle.org"],
693 ),
694 (
696 "github.com",
697 &[
698 "github.com",
699 "api.github.com",
700 "raw.githubusercontent.com",
701 "objects.githubusercontent.com",
702 "codeload.github.com",
703 ],
704 ),
705 (
706 "githubusercontent.com",
707 &[
708 "raw.githubusercontent.com",
709 "objects.githubusercontent.com",
710 "avatars.githubusercontent.com",
711 ],
712 ),
713 (
715 "docker.io",
716 &[
717 "registry-1.docker.io",
718 "auth.docker.io",
719 "production.cloudflare.docker.com",
720 ],
721 ),
722 ("ghcr.io", &["ghcr.io"]),
723 ("gcr.io", &["gcr.io"]),
724 ];
725
726 for (domain, subdomains) in KNOWN {
727 if base == *domain {
728 return subdomains.iter().map(|s| s.to_string()).collect();
729 }
730 }
731
732 vec![base.to_string()]
734}
735
736fn extract_pattern_base(entry: &str) -> Option<String> {
743 if let Some(rest) = entry.strip_prefix("*.") {
745 return Some(rest.to_string());
746 }
747 if let Some(rest) = entry.strip_prefix(".*\\.") {
749 return Some(rest.replace("\\.", "."));
750 }
751 if let Some(rest) = entry.strip_prefix('.')
753 && !rest.is_empty()
754 {
755 return Some(rest.to_string());
756 }
757 None
758}
759
760fn resolve_hostname_into(hostname: &str, entries: &mut Vec<(String, String)>) {
762 let addr = format!("{hostname}:443");
763 if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&addr.as_str()) {
764 for socket_addr in addrs {
765 let ip = socket_addr.ip().to_string();
766 if !entries.iter().any(|(h, a)| h == hostname && a == &ip) {
767 entries.push((hostname.to_string(), ip));
768 }
769 }
770 }
771}
772
773fn resolve_environment(config: &EnvironmentConfig) -> ResolvedEnvironment {
774 let denied: BTreeSet<&str> = config.deny.iter().map(String::as_str).collect();
775 let mut variables = BTreeMap::<String, ResolvedEnvVar>::new();
776
777 for name in &config.pass_through {
778 if denied.contains(name.as_str()) {
779 continue;
780 }
781
782 if let Ok(value) = std::env::var(name) {
783 variables.insert(
784 name.clone(),
785 ResolvedEnvVar {
786 name: name.clone(),
787 value,
788 source: EnvVarSource::PassThrough,
789 },
790 );
791 }
792 }
793
794 for (name, value) in &config.set {
795 if denied.contains(name.as_str()) {
796 continue;
797 }
798 variables.insert(
799 name.clone(),
800 ResolvedEnvVar {
801 name: name.clone(),
802 value: value.clone(),
803 source: EnvVarSource::Set,
804 },
805 );
806 }
807
808 ResolvedEnvironment {
809 variables: variables.into_values().collect(),
810 denied: config.deny.clone(),
811 }
812}
813
814fn resolved_sensitive_pass_through_vars(environment: &ResolvedEnvironment) -> Vec<String> {
815 environment
816 .variables
817 .iter()
818 .filter(|variable| {
819 matches!(variable.source, EnvVarSource::PassThrough)
820 && looks_like_sensitive_env(&variable.name)
821 })
822 .map(|variable| variable.name.clone())
823 .collect()
824}
825
826fn is_install_style(role: &Option<ProfileRole>, profile_name: &str) -> bool {
829 match role {
830 Some(ProfileRole::Install) => true,
831 Some(_) => false,
832 None => {
833 matches!(
834 profile_name,
835 "install" | "deps" | "dependency-install" | "bootstrap"
836 ) || profile_name.contains("install")
837 }
838 }
839}
840
841fn looks_like_sensitive_env(name: &str) -> bool {
842 const EXACT: &[&str] = &[
843 "SSH_AUTH_SOCK",
844 "GITHUB_TOKEN",
845 "GH_TOKEN",
846 "NPM_TOKEN",
847 "NODE_AUTH_TOKEN",
848 "PYPI_TOKEN",
849 "DOCKER_CONFIG",
850 "KUBECONFIG",
851 "GOOGLE_APPLICATION_CREDENTIALS",
852 "AZURE_CLIENT_SECRET",
853 "AWS_SESSION_TOKEN",
854 "AWS_SECRET_ACCESS_KEY",
855 "AWS_ACCESS_KEY_ID",
856 ];
857 const PREFIXES: &[&str] = &["AWS_", "GCP_", "GOOGLE_", "AZURE_", "CLOUDSDK_"];
858
859 EXACT.contains(&name) || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
860}
861
862fn collect_excluded_files(
866 workspace_root: &Path,
867 dir: &Path,
868 pattern: &str,
869 out: &mut Vec<PathBuf>,
870) {
871 let Ok(entries) = std::fs::read_dir(dir) else {
872 return;
873 };
874 for entry in entries.flatten() {
875 let file_type = match entry.file_type() {
876 Ok(ft) => ft,
877 Err(_) => continue,
878 };
879 if file_type.is_symlink() {
881 continue;
882 }
883 let path = entry.path();
884 if file_type.is_dir() {
885 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
886 if matches!(
888 name,
889 ".git"
890 | "node_modules"
891 | "target"
892 | ".venv"
893 | "__pycache__"
894 | "vendor"
895 | "dist"
896 | "build"
897 | ".cache"
898 | ".gradle"
899 | ".tox"
900 ) {
901 continue;
902 }
903 collect_excluded_files(workspace_root, &path, pattern, out);
904 } else if file_type.is_file()
905 && let Ok(rel) = path.strip_prefix(workspace_root)
906 {
907 let rel_str = rel.to_string_lossy();
908 if exclude_pattern_matches(&rel_str, pattern) {
909 out.push(path);
910 }
911 }
912 }
913}
914
915pub(crate) fn exclude_pattern_matches(relative_path: &str, pattern: &str) -> bool {
922 let effective = pattern.trim_start_matches("**/");
923 if effective.contains('/') {
924 glob_match(relative_path, effective)
926 } else {
927 let filename = relative_path.rsplit('/').next().unwrap_or(relative_path);
929 glob_match(filename, effective)
930 }
931}
932
933pub(crate) fn glob_match(s: &str, pattern: &str) -> bool {
935 if !pattern.contains('*') {
936 return s == pattern;
937 }
938 let parts: Vec<&str> = pattern.split('*').collect();
939 let mut remaining = s;
940 for (i, part) in parts.iter().enumerate() {
941 if part.is_empty() {
942 continue;
943 }
944 if i == 0 {
945 if !remaining.starts_with(part) {
946 return false;
947 }
948 remaining = &remaining[part.len()..];
949 } else if i == parts.len() - 1 {
950 return remaining.ends_with(part);
951 } else {
952 match remaining.find(part) {
953 Some(pos) => remaining = &remaining[pos + part.len()..],
954 None => return false,
955 }
956 }
957 }
958 true
959}
960
961fn resolve_mounts(
962 config: &Config,
963 profile: &ProfileConfig,
964 workspace_root: &Path,
965 workspace_mount: &str,
966 profile_writable: bool,
967) -> Vec<ResolvedMount> {
968 let workspace_writable = config
969 .workspace
970 .as_ref()
971 .and_then(|workspace| workspace.writable)
972 .unwrap_or(true)
973 && profile_writable;
974
975 let mut mounts = vec![ResolvedMount {
976 kind: "bind".to_string(),
977 source: Some(workspace_root.to_path_buf()),
978 target: workspace_mount.to_string(),
979 read_only: !workspace_writable,
980 is_workspace: true,
981 create: false,
982 }];
983
984 if !workspace_writable {
988 let writable_paths: &[String] = profile.writable_paths.as_deref().unwrap_or_else(|| {
989 config
990 .workspace
991 .as_ref()
992 .map(|ws| ws.writable_paths.as_slice())
993 .unwrap_or(&[])
994 });
995 for rel_path in writable_paths {
996 mounts.push(ResolvedMount {
997 kind: "bind".to_string(),
998 source: Some(workspace_root.join(rel_path)),
999 target: format!("{workspace_mount}/{rel_path}"),
1000 read_only: false,
1001 is_workspace: true,
1002 create: true,
1003 });
1004 }
1005 }
1006
1007 for mount in &config.mounts {
1008 let source = match mount.mount_type {
1009 MountType::Bind => mount
1010 .source
1011 .as_deref()
1012 .map(|path| resolve_relative_path(path, workspace_root)),
1013 MountType::Tmpfs => None,
1014 };
1015
1016 mounts.push(ResolvedMount {
1017 kind: match mount.mount_type {
1018 MountType::Bind => "bind".to_string(),
1019 MountType::Tmpfs => "tmpfs".to_string(),
1020 },
1021 source,
1022 target: mount.target.clone().expect("validated mount target"),
1023 read_only: mount.read_only.unwrap_or(false),
1024 is_workspace: false,
1025 create: false,
1026 });
1027 }
1028
1029 let exclude_patterns = config
1032 .workspace
1033 .as_ref()
1034 .map(|ws| ws.exclude_paths.as_slice())
1035 .unwrap_or(&[]);
1036 for pattern in exclude_patterns {
1037 let mut matched = Vec::new();
1038 collect_excluded_files(workspace_root, workspace_root, pattern, &mut matched);
1039 for host_path in matched {
1040 if let Ok(rel) = host_path.strip_prefix(workspace_root) {
1041 let target = format!("{workspace_mount}/{}", rel.display());
1042 mounts.push(ResolvedMount {
1043 kind: "mask".to_string(),
1044 source: None,
1045 target,
1046 read_only: true,
1047 is_workspace: true,
1048 create: false,
1049 });
1050 }
1051 }
1052 }
1053
1054 mounts
1055}
1056
1057fn resolve_caches(caches: &[CacheConfig]) -> Vec<ResolvedCache> {
1058 caches
1059 .iter()
1060 .map(|cache| ResolvedCache {
1061 name: cache.name.clone(),
1062 target: cache.target.clone(),
1063 source: cache.source.clone(),
1064 read_only: cache.read_only.unwrap_or(false),
1065 })
1066 .collect()
1067}
1068
1069fn resolve_secrets(
1070 secrets: &[SecretConfig],
1071 active_profile: &str,
1072 active_role: Option<&ProfileRole>,
1073) -> Vec<ResolvedSecret> {
1074 secrets
1075 .iter()
1076 .filter(|secret| {
1077 let profile_ok = secret.when_profiles.is_empty()
1079 || secret.when_profiles.iter().any(|p| p == active_profile);
1080
1081 let role_ok = active_role
1083 .map(|role| !secret.deny_roles.contains(role))
1084 .unwrap_or(true);
1085
1086 profile_ok && role_ok
1087 })
1088 .map(|secret| ResolvedSecret {
1089 name: secret.name.clone(),
1090 source: secret.source.clone(),
1091 target: secret.target.clone(),
1092 })
1093 .collect()
1094}
1095
1096fn resolve_user(config: &Config, rootless: bool) -> ResolvedUser {
1097 match config.identity.as_ref() {
1098 Some(identity) => match (identity.uid, identity.gid) {
1099 (Some(uid), Some(gid)) => ResolvedUser::Explicit { uid, gid },
1100 _ if identity.map_user.unwrap_or(rootless) => ResolvedUser::KeepId,
1101 _ => ResolvedUser::Default,
1102 },
1103 None if rootless => ResolvedUser::KeepId,
1104 None => ResolvedUser::Default,
1105 }
1106}
1107
1108fn resolve_relative_path(path: &Path, base: &Path) -> PathBuf {
1109 if path.is_absolute() {
1110 path.to_path_buf()
1111 } else {
1112 base.join(path)
1113 }
1114}
1115
1116fn join_sandbox_path(mount: &str, relative: &Path) -> String {
1117 let mut path = mount.trim_end_matches('/').to_string();
1118 if path.is_empty() {
1119 path.push('/');
1120 }
1121
1122 for component in relative.components() {
1123 let segment = component.as_os_str().to_string_lossy();
1124 if segment.is_empty() || segment == "." {
1125 continue;
1126 }
1127
1128 if !path.ends_with('/') {
1129 path.push('/');
1130 }
1131 path.push_str(&segment);
1132 }
1133
1134 path
1135}
1136
1137fn stable_hash(input: &str) -> String {
1138 let mut hash = 0xcbf29ce484222325u64;
1139 for byte in input.as_bytes() {
1140 hash ^= u64::from(*byte);
1141 hash = hash.wrapping_mul(0x100000001b3);
1142 }
1143 format!("{hash:016x}")
1144}
1145
1146fn reusable_session_name(config: &Config, workspace_root: &Path, profile_name: &str) -> String {
1147 if let Some(template) = config
1148 .runtime
1149 .as_ref()
1150 .and_then(|runtime| runtime.container_name.as_ref())
1151 {
1152 let workspace_hash = stable_hash(&workspace_root.display().to_string());
1153 return sanitize_session_name(
1154 &template
1155 .replace("{profile}", profile_name)
1156 .replace("{workspace_hash}", &workspace_hash),
1157 );
1158 }
1159
1160 sanitize_session_name(&format!(
1161 "sbox-{}-{}",
1162 stable_hash(&workspace_root.display().to_string()),
1163 profile_name
1164 ))
1165}
1166
1167fn sanitize_session_name(name: &str) -> String {
1168 name.chars()
1169 .map(|ch| {
1170 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-' {
1171 ch
1172 } else {
1173 '-'
1174 }
1175 })
1176 .collect()
1177}
1178
1179fn resolve_preset_reference(preset: &str) -> Result<String, SboxError> {
1180 let reference = match preset {
1181 "python" => "python:3.13-slim",
1182 "node" => "node:22-bookworm-slim",
1183 "rust" => "rust:1-bookworm",
1184 "go" => "golang:1.24-bookworm",
1185 "java" => "eclipse-temurin:21-jdk",
1186 "php" => "php:8.3-cli-bookworm",
1187 "polyglot" => "ubuntu:24.04",
1188 _ => {
1189 return Err(SboxError::UnknownPreset {
1190 name: preset.to_string(),
1191 });
1192 }
1193 };
1194
1195 Ok(reference.to_string())
1196}
1197
1198fn attach_digest(reference: &str, digest: Option<&str>) -> String {
1199 match digest {
1200 Some(digest) if !reference.contains('@') => format!("{reference}@{digest}"),
1201 _ => reference.to_string(),
1202 }
1203}
1204
1205fn classify_reference_trust(reference: &str, digest: Option<&str>) -> ImageTrust {
1206 if digest.is_some() || reference.contains("@sha256:") {
1207 ImageTrust::PinnedDigest
1208 } else {
1209 ImageTrust::MutableReference
1210 }
1211}
1212
1213fn resolve_lockfile_audit(
1215 lockfile_files: &[String],
1216 install_style: bool,
1217 project_dir: &Path,
1218 require_lockfile: Option<bool>,
1219) -> LockfileAudit {
1220 if !install_style || lockfile_files.is_empty() {
1221 return LockfileAudit {
1222 applicable: false,
1223 required: require_lockfile.unwrap_or(false),
1224 present: false,
1225 expected_files: Vec::new(),
1226 };
1227 }
1228
1229 let present = lockfile_files
1230 .iter()
1231 .any(|candidate| project_dir.join(candidate).exists());
1232
1233 LockfileAudit {
1234 applicable: true,
1235 required: require_lockfile.unwrap_or(true),
1236 present,
1237 expected_files: lockfile_files.to_vec(),
1238 }
1239}
1240
1241fn parse_pre_run_commands(pre_run: &[String]) -> Vec<Vec<String>> {
1244 pre_run
1245 .iter()
1246 .filter_map(|s| {
1247 let tokens: Vec<String> = s.split_whitespace().map(str::to_string).collect();
1248 if tokens.is_empty() {
1249 None
1250 } else {
1251 Some(tokens)
1252 }
1253 })
1254 .collect()
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use indexmap::IndexMap;
1260
1261 use super::{
1262 ImageTrust, ProfileSource, ResolutionTarget, ResolvedImageSource, ResolvedUser,
1263 resolve_execution_plan,
1264 };
1265 use crate::cli::{Cli, Commands, PlanCommand};
1266 use crate::config::{
1267 BackendKind,
1268 load::LoadedConfig,
1269 model::{
1270 Config, DispatchRule, ExecutionMode, ImageConfig, ProfileConfig, ProfileRole,
1271 RuntimeConfig, WorkspaceConfig,
1272 },
1273 };
1274
1275 fn base_cli() -> Cli {
1276 Cli {
1277 config: None,
1278 workspace: None,
1279 backend: None,
1280 image: None,
1281 profile: None,
1282 mode: None,
1283 strict_security: false,
1284 verbose: 0,
1285 quiet: false,
1286 command: Commands::Plan(PlanCommand {
1287 show_command: false,
1288 command: vec!["npm".into(), "install".into()],
1289 }),
1290 }
1291 }
1292
1293 fn base_config() -> Config {
1294 let mut profiles = IndexMap::new();
1295 profiles.insert(
1296 "default".to_string(),
1297 ProfileConfig {
1298 mode: ExecutionMode::Sandbox,
1299 image: None,
1300 network: Some("off".to_string()),
1301 writable: Some(true),
1302 require_pinned_image: None,
1303 require_lockfile: None,
1304 role: None,
1305 lockfile_files: Vec::new(),
1306 pre_run: Vec::new(),
1307 network_allow: Vec::new(),
1308 ports: Vec::new(),
1309 capabilities: None,
1310 no_new_privileges: Some(true),
1311 read_only_rootfs: None,
1312 reuse_container: None,
1313 shell: None,
1314
1315 writable_paths: None,
1316 },
1317 );
1318 profiles.insert(
1319 "install".to_string(),
1320 ProfileConfig {
1321 mode: ExecutionMode::Sandbox,
1322 image: None,
1323 network: Some("on".to_string()),
1324 writable: Some(true),
1325 require_pinned_image: None,
1326 require_lockfile: None,
1327 role: Some(ProfileRole::Install),
1328 lockfile_files: Vec::new(),
1329 pre_run: Vec::new(),
1330 network_allow: Vec::new(),
1331 ports: Vec::new(),
1332 capabilities: None,
1333 no_new_privileges: Some(true),
1334 read_only_rootfs: None,
1335 reuse_container: None,
1336 shell: None,
1337
1338 writable_paths: None,
1339 },
1340 );
1341
1342 let mut dispatch = IndexMap::new();
1343 dispatch.insert(
1344 "install".to_string(),
1345 DispatchRule {
1346 patterns: vec!["npm install".to_string()],
1347 profile: "install".to_string(),
1348 },
1349 );
1350
1351 Config {
1352 version: 1,
1353 runtime: Some(RuntimeConfig {
1354 backend: Some(BackendKind::Podman),
1355 rootless: Some(true),
1356 reuse_container: Some(false),
1357 container_name: None,
1358 pull_policy: None,
1359 strict_security: None,
1360 require_pinned_image: None,
1361 }),
1362 workspace: Some(WorkspaceConfig {
1363 root: None,
1364 mount: Some("/workspace".to_string()),
1365 writable: Some(true),
1366 writable_paths: Vec::new(),
1367 exclude_paths: Vec::new(),
1368 }),
1369 identity: None,
1370 image: Some(ImageConfig {
1371 reference: Some("python:3.13-slim".to_string()),
1372 build: None,
1373 preset: None,
1374 digest: None,
1375 verify_signature: None,
1376 pull_policy: None,
1377 tag: None,
1378 }),
1379 environment: None,
1380 mounts: Vec::new(),
1381 caches: Vec::new(),
1382 secrets: Vec::new(),
1383 profiles,
1384 dispatch,
1385
1386 package_manager: None,
1387 }
1388 }
1389
1390 fn loaded_config(config: Config) -> LoadedConfig {
1391 LoadedConfig {
1392 invocation_dir: PathBuf::from("/workspace/project"),
1393 workspace_root: PathBuf::from("/workspace/project"),
1394 config_path: PathBuf::from("/workspace/project/sbox.yaml"),
1395 config,
1396 }
1397 }
1398
1399 use std::path::PathBuf;
1400
1401 #[test]
1402 fn selects_dispatch_profile_in_declaration_order() {
1403 let cli = base_cli();
1404 let plan = resolve_execution_plan(
1405 &cli,
1406 &loaded_config(base_config()),
1407 ResolutionTarget::Plan,
1408 &["npm".into(), "install".into()],
1409 )
1410 .expect("resolution should succeed");
1411
1412 assert_eq!(plan.profile_name, "install");
1413 assert!(matches!(
1414 plan.image.source,
1415 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1416 ));
1417 assert_eq!(plan.image.trust, ImageTrust::MutableReference);
1418 assert!(matches!(plan.user, ResolvedUser::KeepId));
1419 match plan.profile_source {
1420 ProfileSource::Dispatch { rule_name, pattern } => {
1421 assert_eq!(rule_name, "install");
1422 assert_eq!(pattern, "npm install");
1423 }
1424 other => panic!("expected dispatch source, got {other:?}"),
1425 }
1426 }
1427
1428 #[test]
1429 fn falls_back_to_default_profile_when_no_dispatch_matches() {
1430 let cli = base_cli();
1431 let plan = resolve_execution_plan(
1432 &cli,
1433 &loaded_config(base_config()),
1434 ResolutionTarget::Plan,
1435 &["echo".into(), "hello".into()],
1436 )
1437 .expect("resolution should succeed");
1438
1439 assert_eq!(plan.profile_name, "default");
1440 assert!(matches!(plan.profile_source, ProfileSource::DefaultProfile));
1441 assert_eq!(plan.policy.cap_drop, Vec::<String>::new());
1442 }
1443
1444 #[test]
1445 fn workspace_mount_becomes_read_only_when_profile_is_not_writable() {
1446 let cli = base_cli();
1447 let mut config = base_config();
1448 config
1449 .profiles
1450 .get_mut("default")
1451 .expect("default profile exists")
1452 .writable = Some(false);
1453
1454 let plan = resolve_execution_plan(
1455 &cli,
1456 &loaded_config(config),
1457 ResolutionTarget::Plan,
1458 &["echo".into(), "hello".into()],
1459 )
1460 .expect("resolution should succeed");
1461
1462 let workspace_mount = plan
1463 .mounts
1464 .iter()
1465 .find(|mount| mount.is_workspace)
1466 .expect("workspace mount should be present");
1467
1468 assert!(workspace_mount.read_only);
1469 assert!(!plan.policy.writable);
1470 }
1471
1472 #[test]
1473 fn runtime_reuse_container_enables_reusable_session_name() {
1474 let cli = base_cli();
1475 let mut config = base_config();
1476 config
1477 .runtime
1478 .as_mut()
1479 .expect("runtime exists")
1480 .reuse_container = Some(true);
1481
1482 let plan = resolve_execution_plan(
1483 &cli,
1484 &loaded_config(config),
1485 ResolutionTarget::Plan,
1486 &["echo".into(), "hello".into()],
1487 )
1488 .expect("resolution should succeed");
1489
1490 assert!(plan.policy.reuse_container);
1491 assert!(
1492 plan.policy
1493 .reusable_session_name
1494 .as_deref()
1495 .is_some_and(|name| name.starts_with("sbox-"))
1496 );
1497 }
1498
1499 #[test]
1500 fn install_role_marks_install_style() {
1501 let cli = base_cli();
1502 let plan = resolve_execution_plan(
1503 &cli,
1504 &loaded_config(base_config()),
1505 ResolutionTarget::Plan,
1506 &["npm".into(), "install".into()],
1507 )
1508 .expect("resolution should succeed");
1509
1510 assert!(plan.audit.install_style);
1512 assert!(!plan.audit.trusted_image_required);
1513 }
1514
1515 #[test]
1516 fn resolves_known_presets_to_references() {
1517 let cli = base_cli();
1518 let mut config = base_config();
1519 config.image = Some(ImageConfig {
1520 reference: None,
1521 build: None,
1522 preset: Some("python".to_string()),
1523 digest: None,
1524 verify_signature: None,
1525 pull_policy: None,
1526 tag: None,
1527 });
1528
1529 let plan = resolve_execution_plan(
1530 &cli,
1531 &loaded_config(config),
1532 ResolutionTarget::Plan,
1533 &["python".into(), "--version".into()],
1534 )
1535 .expect("resolution should succeed");
1536
1537 assert!(matches!(
1538 plan.image.source,
1539 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1540 ));
1541 }
1542
1543 #[test]
1544 fn profile_can_require_trusted_image() {
1545 let cli = base_cli();
1546 let mut config = base_config();
1547 config
1548 .profiles
1549 .get_mut("install")
1550 .expect("install profile exists")
1551 .require_pinned_image = Some(true);
1552
1553 let plan = resolve_execution_plan(
1554 &cli,
1555 &loaded_config(config),
1556 ResolutionTarget::Plan,
1557 &["npm".into(), "install".into()],
1558 )
1559 .expect("resolution should succeed");
1560
1561 assert!(plan.audit.install_style);
1562 assert!(plan.audit.trusted_image_required);
1563 }
1564
1565 #[test]
1566 fn image_digest_pins_reference_trust() {
1567 let cli = base_cli();
1568 let mut config = base_config();
1569 config.image = Some(ImageConfig {
1570 reference: Some("python:3.13-slim".to_string()),
1571 build: None,
1572 preset: None,
1573 digest: Some("sha256:deadbeef".to_string()),
1574 verify_signature: Some(true),
1575 pull_policy: None,
1576 tag: None,
1577 });
1578
1579 let plan = resolve_execution_plan(
1580 &cli,
1581 &loaded_config(config),
1582 ResolutionTarget::Plan,
1583 &["python".into(), "--version".into()],
1584 )
1585 .expect("resolution should succeed");
1586
1587 assert!(matches!(
1588 plan.image.source,
1589 ResolvedImageSource::Reference(ref image)
1590 if image == "python:3.13-slim@sha256:deadbeef"
1591 ));
1592 assert_eq!(plan.image.trust, ImageTrust::PinnedDigest);
1593 assert!(plan.image.verify_signature);
1594 }
1595
1596 #[test]
1597 fn profile_lockfile_files_drive_lockfile_audit() {
1598 let cli = base_cli();
1599 let mut config = base_config();
1600 let profile = config
1601 .profiles
1602 .get_mut("install")
1603 .expect("install profile exists");
1604 profile.require_lockfile = Some(true);
1605 profile.lockfile_files = vec![
1606 "package-lock.json".to_string(),
1607 "npm-shrinkwrap.json".to_string(),
1608 ];
1609
1610 let plan = resolve_execution_plan(
1611 &cli,
1612 &loaded_config(config),
1613 ResolutionTarget::Plan,
1614 &["npm".into(), "install".into()],
1615 )
1616 .expect("resolution should succeed");
1617
1618 assert!(plan.audit.lockfile.applicable);
1619 assert!(plan.audit.lockfile.required);
1620 assert_eq!(
1621 plan.audit.lockfile.expected_files,
1622 vec!["package-lock.json", "npm-shrinkwrap.json"]
1623 );
1624 }
1625
1626 #[test]
1627 fn pre_run_parses_into_argv_vecs() {
1628 let cli = base_cli();
1629 let mut config = base_config();
1630 config
1631 .profiles
1632 .get_mut("install")
1633 .expect("install profile exists")
1634 .pre_run = vec![
1635 "npm audit --audit-level=high".to_string(),
1636 "echo done".to_string(),
1637 ];
1638
1639 let plan = resolve_execution_plan(
1640 &cli,
1641 &loaded_config(config),
1642 ResolutionTarget::Plan,
1643 &["npm".into(), "install".into()],
1644 )
1645 .expect("resolution should succeed");
1646
1647 assert_eq!(plan.audit.pre_run.len(), 2);
1648 assert_eq!(
1649 plan.audit.pre_run[0],
1650 vec!["npm", "audit", "--audit-level=high"]
1651 );
1652 assert_eq!(plan.audit.pre_run[1], vec!["echo", "done"]);
1653 }
1654
1655 #[test]
1656 fn no_role_profile_name_heuristic_still_marks_install_style() {
1657 let cli = base_cli();
1658 let mut config = base_config();
1659 config.profiles.insert(
1661 "deps".to_string(),
1662 ProfileConfig {
1663 mode: ExecutionMode::Sandbox,
1664 image: None,
1665 network: Some("on".to_string()),
1666 writable: Some(true),
1667 require_pinned_image: None,
1668 require_lockfile: None,
1669 role: None,
1670 lockfile_files: Vec::new(),
1671 pre_run: Vec::new(),
1672 network_allow: Vec::new(),
1673 ports: Vec::new(),
1674 capabilities: None,
1675 no_new_privileges: Some(true),
1676 read_only_rootfs: None,
1677 reuse_container: None,
1678 shell: None,
1679
1680 writable_paths: None,
1681 },
1682 );
1683 config.dispatch.insert(
1684 "uv-sync".to_string(),
1685 crate::config::model::DispatchRule {
1686 patterns: vec!["uv sync".to_string()],
1687 profile: "deps".to_string(),
1688 },
1689 );
1690
1691 let plan = resolve_execution_plan(
1692 &cli,
1693 &loaded_config(config),
1694 ResolutionTarget::Plan,
1695 &["uv".into(), "sync".into()],
1696 )
1697 .expect("resolution should succeed");
1698
1699 assert_eq!(plan.profile_name, "deps");
1700 assert!(plan.audit.install_style); }
1702
1703 #[test]
1704 fn glob_match_exact_and_prefix_suffix_wildcards() {
1705 use super::glob_match;
1706
1707 assert!(glob_match("file.pem", "file.pem"));
1709 assert!(!glob_match("file.pem", "other.pem"));
1710
1711 assert!(glob_match("file.pem", "*.pem"));
1713 assert!(glob_match(".pem", "*.pem"));
1714 assert!(!glob_match("pem", "*.pem"));
1715 assert!(!glob_match("file.pem.bak", "*.pem"));
1716
1717 assert!(glob_match(".env.local", ".env.*"));
1719 assert!(glob_match(".env.production", ".env.*"));
1720 assert!(!glob_match(".env", ".env.*"));
1721
1722 assert!(glob_match("secrets", "secrets*"));
1724 assert!(glob_match("secrets.json", "secrets*"));
1725 assert!(!glob_match("not-secrets", "secrets*"));
1726
1727 assert!(glob_match("my.key.bak", "*.key.*"));
1729 assert!(glob_match("a.key.b", "*.key.*"));
1730 assert!(!glob_match("key.bak", "*.key.*"));
1731 assert!(!glob_match("my.key", "*.key.*"));
1732
1733 assert!(glob_match("abc", "a*b*c"));
1735 assert!(glob_match("aXbYc", "a*b*c"));
1736 assert!(glob_match("abbc", "a*b*c"));
1737 assert!(!glob_match("ac", "a*b*c"));
1738 assert!(!glob_match("aXbYd", "a*b*c"));
1739
1740 assert!(glob_match("XaYbZ", "*a*b*"));
1742 assert!(glob_match("ab", "*a*b*"));
1743 assert!(!glob_match("ba", "*a*b*"));
1744 assert!(!glob_match("XaZ", "*a*b*"));
1745 }
1746
1747 #[test]
1748 fn glob_match_path_patterns() {
1749 use super::exclude_pattern_matches;
1750
1751 assert!(exclude_pattern_matches("dir/file.pem", "*.pem"));
1753 assert!(exclude_pattern_matches("deep/nested/file.key", "*.key"));
1754 assert!(!exclude_pattern_matches("dir/file.pem.bak", "*.pem"));
1755
1756 assert!(exclude_pattern_matches("dir/file.pem", "**/*.pem"));
1758 assert!(exclude_pattern_matches("a/b/c.key", "**/*.key"));
1759
1760 assert!(exclude_pattern_matches("secrets/prod.json", "secrets/*"));
1762 assert!(!exclude_pattern_matches("other/prod.json", "secrets/*"));
1763 assert!(exclude_pattern_matches(
1764 "config/.env.local",
1765 "config/.env.*"
1766 ));
1767 }
1768}