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