use crate::cli::SandboxArgs;
use crate::{package, profile};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub(crate) struct PreparedProfile {
pub(crate) loaded_profile: Option<profile::Profile>,
pub(crate) capability_elevation: bool,
#[cfg(target_os = "linux")]
pub(crate) wsl2_proxy_policy: profile::Wsl2ProxyPolicy,
pub(crate) workdir_access: Option<profile::WorkdirAccess>,
pub(crate) rollback_exclude_patterns: Vec<String>,
pub(crate) rollback_exclude_globs: Vec<String>,
pub(crate) network_profile: Option<String>,
pub(crate) allow_domain: Vec<String>,
pub(crate) credentials: Vec<String>,
pub(crate) custom_credentials: HashMap<String, profile::CustomCredentialDef>,
pub(crate) upstream_proxy: Option<String>,
pub(crate) upstream_bypass: Vec<String>,
pub(crate) listen_ports: Vec<u16>,
pub(crate) open_url_origins: Vec<String>,
pub(crate) open_url_allow_localhost: bool,
pub(crate) allow_launch_services: bool,
pub(crate) allow_gpu: bool,
pub(crate) allow_parent_of_protected: bool,
pub(crate) override_deny_paths: Vec<PathBuf>,
pub(crate) allowed_env_vars: Option<Vec<String>>,
}
#[derive(Clone, Copy)]
struct PrepareProfileOptions {
install_hooks: bool,
hook_output_silent: bool,
}
fn install_profile_hooks(_profile_name: Option<&str>, profile: &profile::Profile, silent: bool) {
if profile.hooks.hooks.is_empty() {
return;
}
if !silent {
for target in profile.hooks.hooks.keys() {
eprintln!(
" Note: profile declares hooks.{target} but in-profile hook \
installation has been removed; move the wiring into the pack's \
package.json `wiring` directives."
);
}
}
}
fn verify_profile_packs(packs: &[String]) -> crate::Result<()> {
if packs.is_empty() {
return Ok(());
}
let lockfile = package::read_lockfile()?;
for pack_ref in packs {
let parts: Vec<&str> = pack_ref.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(nono::NonoError::PackageInstall(format!(
"invalid pack reference '{}': expected <namespace>/<name>",
pack_ref
)));
}
let (namespace, name) = (parts[0], parts[1]);
let install_dir = package::package_install_dir(namespace, name)?;
if !install_dir.exists() {
tracing::warn!(
"Pack '{}' declared by profile but not installed. \
Install it with: nono pull {}",
pack_ref,
pack_ref
);
continue;
}
let locked = lockfile.packages.get(pack_ref);
if let Some(locked_pkg) = locked {
for (artifact_name, locked_artifact) in &locked_pkg.artifacts {
let artifact_path = install_dir.join(artifact_name);
if !artifact_path.exists() {
return Err(nono::NonoError::PackageInstall(format!(
"pack '{}' is missing artifact '{}'. Reinstall with: nono pull {} --force",
pack_ref, artifact_name, pack_ref
)));
}
let bytes = std::fs::read(&artifact_path).map_err(|e| {
nono::NonoError::PackageInstall(format!(
"failed to read artifact '{}' in pack '{}': {}",
artifact_name, pack_ref, e
))
})?;
let digest = Sha256::digest(&bytes);
let hash = digest
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
if hash != locked_artifact.sha256 {
return Err(nono::NonoError::PackageInstall(format!(
"pack '{}' artifact '{}' has been tampered with.\n\
Expected: {}\n\
Found: {}\n\
Reinstall with: nono pull {} --force",
pack_ref, artifact_name, locked_artifact.sha256, hash, pack_ref
)));
}
}
}
let bundle_path = install_dir.join(".nono-trust.bundle");
if bundle_path.exists() {
verify_stored_bundles(&install_dir, &bundle_path, pack_ref)?;
}
}
Ok(())
}
fn verify_stored_bundles(
install_dir: &Path,
bundle_path: &Path,
pack_ref: &str,
) -> crate::Result<()> {
let bundle_content = std::fs::read_to_string(bundle_path).map_err(|e| {
nono::NonoError::PackageInstall(format!(
"failed to read trust bundle for pack '{}': {}",
pack_ref, e
))
})?;
let entries: Vec<serde_json::Value> = serde_json::from_str(&bundle_content).map_err(|e| {
nono::NonoError::PackageInstall(format!(
"failed to parse trust bundle for pack '{}': {}",
pack_ref, e
))
})?;
let trusted_root = nono::trust::load_production_trusted_root()?;
let policy = nono::trust::VerificationPolicy::default();
for entry in &entries {
let artifact_name = entry
.get("artifact")
.and_then(|v| v.as_str())
.ok_or_else(|| {
nono::NonoError::PackageInstall(format!(
"trust bundle entry missing 'artifact' field in pack '{}'",
pack_ref
))
})?;
let bundle_value = entry.get("bundle").ok_or_else(|| {
nono::NonoError::PackageInstall(format!(
"trust bundle entry missing 'bundle' field for '{}' in pack '{}'",
artifact_name, pack_ref
))
})?;
let artifact_path = install_dir.join(artifact_name);
if !artifact_path.exists() {
continue;
}
let artifact_bytes = std::fs::read(&artifact_path).map_err(|e| {
nono::NonoError::PackageInstall(format!(
"failed to read '{}' for bundle verification in pack '{}': {}",
artifact_name, pack_ref, e
))
})?;
let bundle_json = serde_json::to_string(bundle_value).map_err(|e| {
nono::NonoError::PackageInstall(format!(
"failed to serialize bundle for '{}' in pack '{}': {}",
artifact_name, pack_ref, e
))
})?;
let bundle = nono::trust::load_bundle_from_str(
&bundle_json,
Path::new(&format!("{}.bundle", artifact_name)),
)?;
nono::trust::verify_bundle_subject_name(&bundle, Path::new(artifact_name))?;
nono::trust::verify_bundle(
&artifact_bytes,
&bundle,
&trusted_root,
&policy,
Path::new(artifact_name),
)
.map_err(|e| {
nono::NonoError::PackageInstall(format!(
"Sigstore verification failed for '{}' in pack '{}': {}\n\
Reinstall with: nono pull {} --force",
artifact_name, pack_ref, e, pack_ref
))
})?;
}
Ok(())
}
fn expand_override_deny_path(path: &Path, workdir: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
let expanded = profile::expand_vars(&path_str, workdir).unwrap_or_else(|_| path.to_path_buf());
if expanded.exists() {
expanded.canonicalize().unwrap_or(expanded)
} else {
expanded
}
}
fn collect_override_deny_paths(
loaded_profile: Option<&profile::Profile>,
cli_override_deny: &[PathBuf],
workdir: &Path,
) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = loaded_profile
.map(|profile| {
profile
.policy
.override_deny
.iter()
.filter_map(|template| {
profile::expand_vars(template, workdir)
.ok()
.map(|expanded| {
if expanded.exists() {
expanded.canonicalize().unwrap_or(expanded)
} else {
expanded
}
})
})
.collect()
})
.unwrap_or_default();
for path in cli_override_deny {
let canonical = expand_override_deny_path(path, workdir);
if !paths.contains(&canonical) {
paths.push(canonical);
}
}
paths
}
fn prepare_profile_with_options(
args: &SandboxArgs,
workdir: &Path,
options: PrepareProfileOptions,
) -> crate::Result<PreparedProfile> {
if let Ok(config_dir) = profile::resolve_user_config_dir() {
let profiles_dir = config_dir.join("nono").join("profiles");
if !profiles_dir.exists() {
let _ = std::fs::create_dir_all(&profiles_dir);
}
}
let loaded_profile = if let Some(ref profile_name) = args.profile {
let profile = profile::load_profile(profile_name)?;
verify_profile_packs(&profile.packs)?;
if !profile.packs.is_empty() && !options.hook_output_silent {
eprintln!(" Verified {} pack(s)", profile.packs.len());
}
if options.install_hooks {
install_profile_hooks(Some(profile_name), &profile, options.hook_output_silent);
}
Some(profile)
} else {
None
};
Ok(PreparedProfile {
capability_elevation: loaded_profile
.as_ref()
.and_then(|profile| profile.security.capability_elevation)
.unwrap_or(false),
#[cfg(target_os = "linux")]
wsl2_proxy_policy: loaded_profile
.as_ref()
.and_then(|profile| profile.security.wsl2_proxy_policy)
.unwrap_or_default(),
workdir_access: loaded_profile
.as_ref()
.map(|profile| profile.workdir.access.clone()),
rollback_exclude_patterns: loaded_profile
.as_ref()
.map(|profile| profile.rollback.exclude_patterns.clone())
.unwrap_or_default(),
rollback_exclude_globs: loaded_profile
.as_ref()
.map(|profile| profile.rollback.exclude_globs.clone())
.unwrap_or_default(),
network_profile: loaded_profile.as_ref().and_then(|profile| {
profile
.network
.resolved_network_profile()
.map(|value| value.to_string())
}),
allow_domain: loaded_profile
.as_ref()
.map(|profile| profile.network.allow_domain.clone())
.unwrap_or_default(),
credentials: loaded_profile
.as_ref()
.and_then(|profile| profile.network.credentials.clone())
.unwrap_or_default(),
custom_credentials: loaded_profile
.as_ref()
.map(|profile| profile.network.custom_credentials.clone())
.unwrap_or_default(),
upstream_proxy: loaded_profile
.as_ref()
.and_then(|profile| profile.network.upstream_proxy.clone()),
upstream_bypass: loaded_profile
.as_ref()
.map(|profile| profile.network.upstream_bypass.clone())
.unwrap_or_default(),
listen_ports: loaded_profile
.as_ref()
.map(|profile| profile.network.listen_port.clone())
.unwrap_or_default(),
open_url_origins: loaded_profile
.as_ref()
.and_then(|profile| profile.open_urls.as_ref())
.map(|open_urls| open_urls.allow_origins.clone())
.unwrap_or_default(),
open_url_allow_localhost: loaded_profile
.as_ref()
.and_then(|profile| profile.open_urls.as_ref())
.map(|open_urls| open_urls.allow_localhost)
.unwrap_or(false),
allow_launch_services: loaded_profile
.as_ref()
.and_then(|profile| profile.allow_launch_services)
.unwrap_or(false),
allow_gpu: loaded_profile
.as_ref()
.and_then(|profile| profile.allow_gpu)
.unwrap_or(false),
allow_parent_of_protected: loaded_profile
.as_ref()
.and_then(|profile| profile.allow_parent_of_protected)
.unwrap_or(false),
override_deny_paths: collect_override_deny_paths(
loaded_profile.as_ref(),
&args.override_deny,
workdir,
),
allowed_env_vars: loaded_profile.as_ref().and_then(|profile| {
profile.environment.as_ref().map(|env_config| {
if let Some(err) =
crate::exec_strategy::validate_allow_vars_pattern(&env_config.allow_vars)
{
eprintln!("Warning: {}", err);
}
env_config.allow_vars.clone()
})
}),
loaded_profile,
})
}
pub(crate) fn prepare_profile(
args: &SandboxArgs,
silent: bool,
workdir: &Path,
) -> crate::Result<PreparedProfile> {
prepare_profile_with_options(
args,
workdir,
PrepareProfileOptions {
install_hooks: true,
hook_output_silent: silent,
},
)
}
pub(crate) fn prepare_profile_for_preflight(
args: &SandboxArgs,
workdir: &Path,
) -> crate::Result<PreparedProfile> {
prepare_profile_with_options(
args,
workdir,
PrepareProfileOptions {
install_hooks: false,
hook_output_silent: true,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn prepare_profile_for_preflight_matches_runtime_resolution() {
let workdir = match tempdir() {
Ok(dir) => dir,
Err(err) => panic!("failed to create tempdir: {err}"),
};
let cli_override = workdir.path().join("cli-override");
if let Err(err) = fs::create_dir_all(&cli_override) {
panic!("failed to create CLI override path: {err}");
}
let profile_path = workdir.path().join("preflight-profile.json");
if let Err(err) = fs::write(
&profile_path,
r#"{
"extends": "default",
"meta": { "name": "preflight-profile" },
"workdir": { "access": "write" },
"rollback": { "exclude_patterns": ["target"] },
"network": {
"allow_domain": ["example.com"],
"upstream_bypass": ["localhost"],
"listen_port": [8080]
},
"policy": {
"override_deny": ["$WORKDIR/.git"]
}
}"#,
) {
panic!("failed to write profile: {err}");
}
let args = SandboxArgs {
profile: Some(profile_path.to_string_lossy().into_owned()),
override_deny: vec![cli_override],
..SandboxArgs::default()
};
let runtime = match prepare_profile(&args, true, workdir.path()) {
Ok(profile) => profile,
Err(err) => panic!("runtime prepare_profile failed: {err}"),
};
let preflight = match prepare_profile_for_preflight(&args, workdir.path()) {
Ok(profile) => profile,
Err(err) => panic!("preflight prepare_profile failed: {err}"),
};
assert_eq!(runtime.capability_elevation, preflight.capability_elevation);
#[cfg(target_os = "linux")]
assert_eq!(runtime.wsl2_proxy_policy, preflight.wsl2_proxy_policy);
assert_eq!(runtime.workdir_access, preflight.workdir_access);
assert_eq!(
runtime.rollback_exclude_patterns,
preflight.rollback_exclude_patterns
);
assert_eq!(
runtime.rollback_exclude_globs,
preflight.rollback_exclude_globs
);
assert_eq!(runtime.network_profile, preflight.network_profile);
assert_eq!(runtime.allow_domain, preflight.allow_domain);
assert_eq!(runtime.credentials, preflight.credentials);
assert_eq!(runtime.custom_credentials, preflight.custom_credentials);
assert_eq!(runtime.upstream_proxy, preflight.upstream_proxy);
assert_eq!(runtime.upstream_bypass, preflight.upstream_bypass);
assert_eq!(runtime.listen_ports, preflight.listen_ports);
assert_eq!(runtime.open_url_origins, preflight.open_url_origins);
assert_eq!(
runtime.open_url_allow_localhost,
preflight.open_url_allow_localhost
);
assert_eq!(
runtime.allow_launch_services,
preflight.allow_launch_services
);
assert_eq!(runtime.allow_gpu, preflight.allow_gpu);
assert_eq!(runtime.override_deny_paths, preflight.override_deny_paths);
assert_eq!(
runtime.loaded_profile.as_ref().map(|profile| {
(
profile.meta.name.clone(),
profile.extends.clone(),
profile.security.groups.clone(),
profile.workdir.access.clone(),
profile.filesystem.allow.clone(),
)
}),
preflight.loaded_profile.as_ref().map(|profile| {
(
profile.meta.name.clone(),
profile.extends.clone(),
profile.security.groups.clone(),
profile.workdir.access.clone(),
profile.filesystem.allow.clone(),
)
})
);
}
}