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