Skip to main content

sbox/
resolve.rs

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    /// Pre-run commands to execute on the host before the sandboxed command.
42    /// Each inner Vec is a tokenised command (argv), parsed from the profile's `pre_run` strings.
43    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    /// Resolved allow-list: `(hostname, ip)` pairs injected as `--add-host` entries.
104    /// Empty means no restriction (full network or network off).
105    pub network_allow: Vec<(String, String)>,
106    /// Original glob/regex patterns from `network_allow` that were expanded at resolve time.
107    /// Stored for plan display. Enforcement is via the resolved base-domain IPs in `network_allow`.
108    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    /// When true, the host source directory is created automatically if it does not exist.
138    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    // Probe PATH: prefer podman (rootless-first), fall back to docker.
379    if which_on_path("podman") {
380        return BackendKind::Podman;
381    }
382    if which_on_path("docker") {
383        return BackendKind::Docker;
384    }
385    // Default to podman — execution will fail with a clear "backend unavailable" error.
386    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            // Rejected by validation; unreachable in a valid config.
607            (Vec::new(), Vec::new())
608        }
609        None => (Vec::new(), Vec::new()),
610    }
611}
612
613/// Resolve `network_allow` entries to `(hostname, ip)` pairs plus raw patterns.
614///
615/// Each entry is either:
616/// - An exact hostname (`registry.npmjs.org`) → DNS-resolved to `(hostname, ip)` pairs
617/// - A glob pattern (`*.npmjs.org`) → base domain resolved + original pattern recorded
618/// - A regex-style prefix pattern (`.*\.npmjs\.org`) → same as glob
619///
620/// Returns `(resolved_pairs, patterns)`.
621/// Only active when `network` is not `off`.
622fn 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            // It's a glob/regex pattern — store the original, expand to known subdomains.
639            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
651/// Expand a base domain to the set of hostnames to resolve for a wildcard pattern.
652///
653/// For well-known package registry domains we enumerate the subdomains that package
654/// managers actually use, so `*.npmjs.org` resolves `registry.npmjs.org` and friends
655/// rather than just `npmjs.org` itself.  For unknown domains the base is returned as-is.
656fn expand_pattern_hosts(base: &str) -> Vec<String> {
657    const KNOWN: &[(&str, &[&str])] = &[
658        // npm / yarn
659        ("npmjs.org",          &["registry.npmjs.org", "npmjs.org", "www.npmjs.org"]),
660        ("yarnpkg.com",        &["registry.yarnpkg.com", "yarnpkg.com"]),
661        // Python
662        ("pypi.org",           &["pypi.org", "files.pythonhosted.org"]),
663        ("pythonhosted.org",   &["files.pythonhosted.org", "pythonhosted.org"]),
664        // Rust
665        ("crates.io",          &["crates.io", "static.crates.io", "index.crates.io"]),
666        // Go
667        ("golang.org",         &["proxy.golang.org", "sum.golang.org", "golang.org"]),
668        ("go.dev",             &["proxy.golang.dev", "sum.golang.dev", "go.dev"]),
669        // Ruby
670        ("rubygems.org",       &["rubygems.org", "api.rubygems.org", "index.rubygems.org"]),
671        // Java / Gradle / Maven
672        ("maven.org",          &["repo1.maven.org", "central.maven.org"]),
673        ("gradle.org",         &["plugins.gradle.org", "services.gradle.org", "gradle.org"]),
674        // GitHub (source deps)
675        ("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 / OCI registries
680        ("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    // Unknown domain — resolve just the base; a DNS proxy would be needed for full wildcard coverage.
692    vec![base.to_string()]
693}
694
695/// Return the concrete base domain for a glob/regex pattern, or `None` if the entry is exact.
696///
697/// Supported forms:
698/// - `*.example.com`        → `example.com`
699/// - `.*\.example\.com`     → `example.com`   (regex prefix `.*\.`)
700/// - `.example.com`         → `example.com`   (leading-dot notation)
701fn extract_pattern_base(entry: &str) -> Option<String> {
702    // Glob: *.example.com
703    if let Some(rest) = entry.strip_prefix("*.") {
704        return Some(rest.to_string());
705    }
706    // Regex: .*\.example\.com — strip leading `.*\.` and unescape `\.` → `.`
707    if let Some(rest) = entry.strip_prefix(".*\\.") {
708        return Some(rest.replace("\\.", "."));
709    }
710    // Leading-dot notation: .example.com
711    if let Some(rest) = entry.strip_prefix('.') {
712        if !rest.is_empty() {
713            return Some(rest.to_string());
714        }
715    }
716    None
717}
718
719/// DNS-resolve `hostname` and append unique `(hostname, ip)` pairs into `entries`.
720fn 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
785/// Returns true when the resolved profile declares itself as an install profile.
786/// Falls back to well-known profile-name conventions when no explicit role is set.
787fn 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
821/// Walk `dir` recursively and collect paths of files that match `pattern`.
822/// Skips large build/tool directories (.git, node_modules, target, .venv) for performance.
823/// Does not follow symlinks.
824fn 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        // Never follow symlinks — avoids loops and unintended host path access
834        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            // Skip large build/tool dirs that don't contain user credentials
841            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
869/// Return true if `relative_path` (e.g. `"config/.env"`) matches `pattern`.
870///
871/// Pattern rules:
872/// - Leading `**/` is stripped; the rest is matched against the filename only
873///   unless it contains a `/`, in which case it is matched against the full relative path.
874/// - `*` matches any sequence of characters within a single path component.
875pub(crate) fn exclude_pattern_matches(relative_path: &str, pattern: &str) -> bool {
876    let effective = pattern.trim_start_matches("**/");
877    if effective.contains('/') {
878        // Path-relative pattern: match against the full relative path
879        glob_match(relative_path, effective)
880    } else {
881        // Filename-only pattern: match only the last path component
882        let filename = relative_path
883            .rsplit('/')
884            .next()
885            .unwrap_or(relative_path);
886        glob_match(filename, effective)
887    }
888}
889
890/// Simple glob matcher supporting `*` wildcards (no path-separator crossing).
891pub(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    // When the workspace is read-only, inject rw bind mounts for each writable_path.
942    // Profile-level writable_paths override workspace-level when set.
943    // These are mounted after the workspace so Podman's mount ordering gives them precedence.
944    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    // Mask credential/secret files with /dev/null overlays — independent of workspace writability.
990    // These mounts are added last so they take precedence over the workspace bind mount.
991    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
1165/// Resolve lockfile audit from the profile's explicit `lockfile_files` list.
1166fn 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
1193/// Parse `pre_run` strings (e.g. `"npm audit --audit-level=high"`) into argv vecs.
1194/// Uses simple whitespace splitting — no shell quoting support needed for command names.
1195fn 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        // dispatched to "install" profile which has role: install
1459        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        // "deps" profile name matches the heuristic even without an explicit role
1596        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); // heuristic via name "deps"
1637    }
1638
1639    #[test]
1640    fn glob_match_exact_and_prefix_suffix_wildcards() {
1641        use super::glob_match;
1642
1643        // Exact match (no wildcard)
1644        assert!(glob_match("file.pem", "file.pem"));
1645        assert!(!glob_match("file.pem", "other.pem"));
1646
1647        // Suffix wildcard: *.pem
1648        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        // Prefix wildcard: .env.*
1654        assert!(glob_match(".env.local", ".env.*"));
1655        assert!(glob_match(".env.production", ".env.*"));
1656        assert!(!glob_match(".env", ".env.*"));
1657
1658        // Trailing wildcard: secrets*
1659        assert!(glob_match("secrets", "secrets*"));
1660        assert!(glob_match("secrets.json", "secrets*"));
1661        assert!(!glob_match("not-secrets", "secrets*"));
1662
1663        // Both-ends wildcard: *.key.*
1664        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        // Three-wildcard pattern: a*b*c
1670        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        // Pattern with no anchoring (*a*b*)
1677        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        // Filename-only pattern (no slash): matches against filename
1688        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        // Leading **/ is stripped before matching
1693        assert!(exclude_pattern_matches("dir/file.pem", "**/*.pem"));
1694        assert!(exclude_pattern_matches("a/b/c.key", "**/*.key"));
1695
1696        // Path-relative pattern (contains slash): matches full relative path
1697        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}