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