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 audit: false,
1305 command: vec!["npm".into(), "install".into()],
1306 }),
1307 }
1308 }
1309
1310 fn base_config() -> Config {
1311 let mut profiles = IndexMap::new();
1312 profiles.insert(
1313 "default".to_string(),
1314 ProfileConfig {
1315 mode: ExecutionMode::Sandbox,
1316 image: None,
1317 network: Some("off".to_string()),
1318 writable: Some(true),
1319 require_pinned_image: None,
1320 require_lockfile: None,
1321 role: None,
1322 lockfile_files: Vec::new(),
1323 pre_run: Vec::new(),
1324 network_allow: Vec::new(),
1325 ports: Vec::new(),
1326 capabilities: None,
1327 no_new_privileges: Some(true),
1328 read_only_rootfs: None,
1329 reuse_container: None,
1330 shell: None,
1331
1332 writable_paths: None,
1333 },
1334 );
1335 profiles.insert(
1336 "install".to_string(),
1337 ProfileConfig {
1338 mode: ExecutionMode::Sandbox,
1339 image: None,
1340 network: Some("on".to_string()),
1341 writable: Some(true),
1342 require_pinned_image: None,
1343 require_lockfile: None,
1344 role: Some(ProfileRole::Install),
1345 lockfile_files: Vec::new(),
1346 pre_run: Vec::new(),
1347 network_allow: Vec::new(),
1348 ports: Vec::new(),
1349 capabilities: None,
1350 no_new_privileges: Some(true),
1351 read_only_rootfs: None,
1352 reuse_container: None,
1353 shell: None,
1354
1355 writable_paths: None,
1356 },
1357 );
1358
1359 let mut dispatch = IndexMap::new();
1360 dispatch.insert(
1361 "install".to_string(),
1362 DispatchRule {
1363 patterns: vec!["npm install".to_string()],
1364 profile: "install".to_string(),
1365 },
1366 );
1367
1368 Config {
1369 version: 1,
1370 runtime: Some(RuntimeConfig {
1371 backend: Some(BackendKind::Podman),
1372 rootless: Some(true),
1373 reuse_container: Some(false),
1374 container_name: None,
1375 pull_policy: None,
1376 strict_security: None,
1377 require_pinned_image: None,
1378 }),
1379 workspace: Some(WorkspaceConfig {
1380 root: None,
1381 mount: Some("/workspace".to_string()),
1382 writable: Some(true),
1383 writable_paths: Vec::new(),
1384 exclude_paths: Vec::new(),
1385 }),
1386 identity: None,
1387 image: Some(ImageConfig {
1388 reference: Some("python:3.13-slim".to_string()),
1389 build: None,
1390 preset: None,
1391 digest: None,
1392 verify_signature: None,
1393 pull_policy: None,
1394 tag: None,
1395 }),
1396 environment: None,
1397 mounts: Vec::new(),
1398 caches: Vec::new(),
1399 secrets: Vec::new(),
1400 profiles,
1401 dispatch,
1402
1403 package_manager: None,
1404 }
1405 }
1406
1407 fn loaded_config(config: Config) -> LoadedConfig {
1408 LoadedConfig {
1409 invocation_dir: PathBuf::from("/workspace/project"),
1410 workspace_root: PathBuf::from("/workspace/project"),
1411 config_path: PathBuf::from("/workspace/project/sbox.yaml"),
1412 config,
1413 }
1414 }
1415
1416 use std::path::PathBuf;
1417
1418 #[test]
1419 fn selects_dispatch_profile_in_declaration_order() {
1420 let cli = base_cli();
1421 let plan = resolve_execution_plan(
1422 &cli,
1423 &loaded_config(base_config()),
1424 ResolutionTarget::Plan,
1425 &["npm".into(), "install".into()],
1426 )
1427 .expect("resolution should succeed");
1428
1429 assert_eq!(plan.profile_name, "install");
1430 assert!(matches!(
1431 plan.image.source,
1432 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1433 ));
1434 assert_eq!(plan.image.trust, ImageTrust::MutableReference);
1435 assert!(matches!(plan.user, ResolvedUser::KeepId));
1436 match plan.profile_source {
1437 ProfileSource::Dispatch { rule_name, pattern } => {
1438 assert_eq!(rule_name, "install");
1439 assert_eq!(pattern, "npm install");
1440 }
1441 other => panic!("expected dispatch source, got {other:?}"),
1442 }
1443 }
1444
1445 #[test]
1446 fn falls_back_to_default_profile_when_no_dispatch_matches() {
1447 let cli = base_cli();
1448 let plan = resolve_execution_plan(
1449 &cli,
1450 &loaded_config(base_config()),
1451 ResolutionTarget::Plan,
1452 &["echo".into(), "hello".into()],
1453 )
1454 .expect("resolution should succeed");
1455
1456 assert_eq!(plan.profile_name, "default");
1457 assert!(matches!(plan.profile_source, ProfileSource::DefaultProfile));
1458 assert_eq!(plan.policy.cap_drop, Vec::<String>::new());
1459 }
1460
1461 #[test]
1462 fn workspace_mount_becomes_read_only_when_profile_is_not_writable() {
1463 let cli = base_cli();
1464 let mut config = base_config();
1465 config
1466 .profiles
1467 .get_mut("default")
1468 .expect("default profile exists")
1469 .writable = Some(false);
1470
1471 let plan = resolve_execution_plan(
1472 &cli,
1473 &loaded_config(config),
1474 ResolutionTarget::Plan,
1475 &["echo".into(), "hello".into()],
1476 )
1477 .expect("resolution should succeed");
1478
1479 let workspace_mount = plan
1480 .mounts
1481 .iter()
1482 .find(|mount| mount.is_workspace)
1483 .expect("workspace mount should be present");
1484
1485 assert!(workspace_mount.read_only);
1486 assert!(!plan.policy.writable);
1487 }
1488
1489 #[test]
1490 fn runtime_reuse_container_enables_reusable_session_name() {
1491 let cli = base_cli();
1492 let mut config = base_config();
1493 config
1494 .runtime
1495 .as_mut()
1496 .expect("runtime exists")
1497 .reuse_container = Some(true);
1498
1499 let plan = resolve_execution_plan(
1500 &cli,
1501 &loaded_config(config),
1502 ResolutionTarget::Plan,
1503 &["echo".into(), "hello".into()],
1504 )
1505 .expect("resolution should succeed");
1506
1507 assert!(plan.policy.reuse_container);
1508 assert!(
1509 plan.policy
1510 .reusable_session_name
1511 .as_deref()
1512 .is_some_and(|name| name.starts_with("sbox-"))
1513 );
1514 }
1515
1516 #[test]
1517 fn install_role_marks_install_style() {
1518 let cli = base_cli();
1519 let plan = resolve_execution_plan(
1520 &cli,
1521 &loaded_config(base_config()),
1522 ResolutionTarget::Plan,
1523 &["npm".into(), "install".into()],
1524 )
1525 .expect("resolution should succeed");
1526
1527 assert!(plan.audit.install_style);
1529 assert!(!plan.audit.trusted_image_required);
1530 }
1531
1532 #[test]
1533 fn resolves_known_presets_to_references() {
1534 let cli = base_cli();
1535 let mut config = base_config();
1536 config.image = Some(ImageConfig {
1537 reference: None,
1538 build: None,
1539 preset: Some("python".to_string()),
1540 digest: None,
1541 verify_signature: None,
1542 pull_policy: None,
1543 tag: None,
1544 });
1545
1546 let plan = resolve_execution_plan(
1547 &cli,
1548 &loaded_config(config),
1549 ResolutionTarget::Plan,
1550 &["python".into(), "--version".into()],
1551 )
1552 .expect("resolution should succeed");
1553
1554 assert!(matches!(
1555 plan.image.source,
1556 ResolvedImageSource::Reference(ref image) if image == "python:3.13-slim"
1557 ));
1558 }
1559
1560 #[test]
1561 fn profile_can_require_trusted_image() {
1562 let cli = base_cli();
1563 let mut config = base_config();
1564 config
1565 .profiles
1566 .get_mut("install")
1567 .expect("install profile exists")
1568 .require_pinned_image = Some(true);
1569
1570 let plan = resolve_execution_plan(
1571 &cli,
1572 &loaded_config(config),
1573 ResolutionTarget::Plan,
1574 &["npm".into(), "install".into()],
1575 )
1576 .expect("resolution should succeed");
1577
1578 assert!(plan.audit.install_style);
1579 assert!(plan.audit.trusted_image_required);
1580 }
1581
1582 #[test]
1583 fn image_digest_pins_reference_trust() {
1584 let cli = base_cli();
1585 let mut config = base_config();
1586 config.image = Some(ImageConfig {
1587 reference: Some("python:3.13-slim".to_string()),
1588 build: None,
1589 preset: None,
1590 digest: Some("sha256:deadbeef".to_string()),
1591 verify_signature: Some(true),
1592 pull_policy: None,
1593 tag: None,
1594 });
1595
1596 let plan = resolve_execution_plan(
1597 &cli,
1598 &loaded_config(config),
1599 ResolutionTarget::Plan,
1600 &["python".into(), "--version".into()],
1601 )
1602 .expect("resolution should succeed");
1603
1604 assert!(matches!(
1605 plan.image.source,
1606 ResolvedImageSource::Reference(ref image)
1607 if image == "python:3.13-slim@sha256:deadbeef"
1608 ));
1609 assert_eq!(plan.image.trust, ImageTrust::PinnedDigest);
1610 assert!(plan.image.verify_signature);
1611 }
1612
1613 #[test]
1614 fn profile_lockfile_files_drive_lockfile_audit() {
1615 let cli = base_cli();
1616 let mut config = base_config();
1617 let profile = config
1618 .profiles
1619 .get_mut("install")
1620 .expect("install profile exists");
1621 profile.require_lockfile = Some(true);
1622 profile.lockfile_files = vec![
1623 "package-lock.json".to_string(),
1624 "npm-shrinkwrap.json".to_string(),
1625 ];
1626
1627 let plan = resolve_execution_plan(
1628 &cli,
1629 &loaded_config(config),
1630 ResolutionTarget::Plan,
1631 &["npm".into(), "install".into()],
1632 )
1633 .expect("resolution should succeed");
1634
1635 assert!(plan.audit.lockfile.applicable);
1636 assert!(plan.audit.lockfile.required);
1637 assert_eq!(
1638 plan.audit.lockfile.expected_files,
1639 vec!["package-lock.json", "npm-shrinkwrap.json"]
1640 );
1641 }
1642
1643 #[test]
1644 fn pre_run_parses_into_argv_vecs() {
1645 let cli = base_cli();
1646 let mut config = base_config();
1647 config
1648 .profiles
1649 .get_mut("install")
1650 .expect("install profile exists")
1651 .pre_run = vec![
1652 "npm audit --audit-level=high".to_string(),
1653 "echo done".to_string(),
1654 ];
1655
1656 let plan = resolve_execution_plan(
1657 &cli,
1658 &loaded_config(config),
1659 ResolutionTarget::Plan,
1660 &["npm".into(), "install".into()],
1661 )
1662 .expect("resolution should succeed");
1663
1664 assert_eq!(plan.audit.pre_run.len(), 2);
1665 assert_eq!(
1666 plan.audit.pre_run[0],
1667 vec!["npm", "audit", "--audit-level=high"]
1668 );
1669 assert_eq!(plan.audit.pre_run[1], vec!["echo", "done"]);
1670 }
1671
1672 #[test]
1673 fn no_role_profile_name_heuristic_still_marks_install_style() {
1674 let cli = base_cli();
1675 let mut config = base_config();
1676 config.profiles.insert(
1678 "deps".to_string(),
1679 ProfileConfig {
1680 mode: ExecutionMode::Sandbox,
1681 image: None,
1682 network: Some("on".to_string()),
1683 writable: Some(true),
1684 require_pinned_image: None,
1685 require_lockfile: None,
1686 role: None,
1687 lockfile_files: Vec::new(),
1688 pre_run: Vec::new(),
1689 network_allow: Vec::new(),
1690 ports: Vec::new(),
1691 capabilities: None,
1692 no_new_privileges: Some(true),
1693 read_only_rootfs: None,
1694 reuse_container: None,
1695 shell: None,
1696
1697 writable_paths: None,
1698 },
1699 );
1700 config.dispatch.insert(
1701 "uv-sync".to_string(),
1702 crate::config::model::DispatchRule {
1703 patterns: vec!["uv sync".to_string()],
1704 profile: "deps".to_string(),
1705 },
1706 );
1707
1708 let plan = resolve_execution_plan(
1709 &cli,
1710 &loaded_config(config),
1711 ResolutionTarget::Plan,
1712 &["uv".into(), "sync".into()],
1713 )
1714 .expect("resolution should succeed");
1715
1716 assert_eq!(plan.profile_name, "deps");
1717 assert!(plan.audit.install_style); }
1719
1720 #[test]
1721 fn glob_match_exact_and_prefix_suffix_wildcards() {
1722 use super::glob_match;
1723
1724 assert!(glob_match("file.pem", "file.pem"));
1726 assert!(!glob_match("file.pem", "other.pem"));
1727
1728 assert!(glob_match("file.pem", "*.pem"));
1730 assert!(glob_match(".pem", "*.pem"));
1731 assert!(!glob_match("pem", "*.pem"));
1732 assert!(!glob_match("file.pem.bak", "*.pem"));
1733
1734 assert!(glob_match(".env.local", ".env.*"));
1736 assert!(glob_match(".env.production", ".env.*"));
1737 assert!(!glob_match(".env", ".env.*"));
1738
1739 assert!(glob_match("secrets", "secrets*"));
1741 assert!(glob_match("secrets.json", "secrets*"));
1742 assert!(!glob_match("not-secrets", "secrets*"));
1743
1744 assert!(glob_match("my.key.bak", "*.key.*"));
1746 assert!(glob_match("a.key.b", "*.key.*"));
1747 assert!(!glob_match("key.bak", "*.key.*"));
1748 assert!(!glob_match("my.key", "*.key.*"));
1749
1750 assert!(glob_match("abc", "a*b*c"));
1752 assert!(glob_match("aXbYc", "a*b*c"));
1753 assert!(glob_match("abbc", "a*b*c"));
1754 assert!(!glob_match("ac", "a*b*c"));
1755 assert!(!glob_match("aXbYd", "a*b*c"));
1756
1757 assert!(glob_match("XaYbZ", "*a*b*"));
1759 assert!(glob_match("ab", "*a*b*"));
1760 assert!(!glob_match("ba", "*a*b*"));
1761 assert!(!glob_match("XaZ", "*a*b*"));
1762 }
1763
1764 #[test]
1765 fn glob_match_path_patterns() {
1766 use super::exclude_pattern_matches;
1767
1768 assert!(exclude_pattern_matches("dir/file.pem", "*.pem"));
1770 assert!(exclude_pattern_matches("deep/nested/file.key", "*.key"));
1771 assert!(!exclude_pattern_matches("dir/file.pem.bak", "*.pem"));
1772
1773 assert!(exclude_pattern_matches("dir/file.pem", "**/*.pem"));
1775 assert!(exclude_pattern_matches("a/b/c.key", "**/*.key"));
1776
1777 assert!(exclude_pattern_matches("secrets/prod.json", "secrets/*"));
1779 assert!(!exclude_pattern_matches("other/prod.json", "secrets/*"));
1780 assert!(exclude_pattern_matches(
1781 "config/.env.local",
1782 "config/.env.*"
1783 ));
1784 }
1785}