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,
#[cfg(target_os = "linux")]
pub(crate) af_unix_mediation: profile::LinuxAfUnixMediation,
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<profile::AllowDomainEntry>,
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) bypass_protection_paths: Vec<PathBuf>,
pub(crate) ignored_denial_paths: Vec<PathBuf>,
pub(crate) suppressed_system_service_operations: Vec<String>,
pub(crate) allowed_env_vars: Option<Vec<String>>,
pub(crate) denied_env_vars: Option<Vec<String>>,
pub(crate) set_vars: Option<Vec<(String, 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], profile: &profile::Profile) -> crate::Result<()> {
if let Some(hook) = [&profile.session_hooks.before, &profile.session_hooks.after]
.into_iter()
.flatten()
.find(|hook| {
hook.source_pack
.as_ref()
.is_some_and(|sp| !packs.contains(&sp.key()))
})
{
return Err(nono::NonoError::PackageInstall(format!(
"session_hook {} unexpectedly is not part of the packs to verify",
hook.script.display()
)));
}
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_pkg = lockfile.packages.get(pack_ref).ok_or_else(|| {
nono::NonoError::PackageVerification {
package: pack_ref.clone(),
reason: format!(
"pack '{}' has no lockfile entry - reinstall with: nono pull {} --force",
pack_ref, pack_ref
),
}
})?;
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
)));
}
for script_path in [&profile.session_hooks.before, &profile.session_hooks.after]
.into_iter()
.flatten()
.filter(|hook| {
hook.source_pack
.as_ref()
.is_some_and(|sp| sp.key() == *pack_ref)
})
.map(|hook| hook.script.as_path())
{
let relative_path = script_path
.strip_prefix(&install_dir)
.map_err(|_| {
nono::NonoError::PackageInstall(format!(
"session_hook with path {} is not within the pack",
script_path.display()
))
})?
.to_str()
.ok_or_else(|| {
nono::NonoError::PackageInstall(
"Invalid script_path characters".to_string(),
)
})?;
if !locked_pkg.artifacts.contains_key(relative_path) {
return Err(nono::NonoError::PackageInstall(format!(
"session_hook with path {} is not a declared artifact in the pack lockfile",
script_path.display()
)));
}
}
}
let bundle_path = install_dir.join(".nono-trust.bundle");
if !bundle_path.exists() {
return Err(nono::NonoError::PackageVerification {
package: pack_ref.clone(),
reason: format!(
"pack '{}' is missing .nono-trust.bundle - reinstall with: nono pull {} --force",
pack_ref, pack_ref
),
});
}
let pinned_signer = locked_pkg
.provenance
.as_ref()
.map(|p| p.signer_identity.as_str())
.ok_or_else(|| nono::NonoError::PackageVerification {
package: pack_ref.clone(),
reason: format!(
"pack '{}' has no signer identity in the lockfile - reinstall with: nono pull {} --force",
pack_ref, pack_ref
),
})?;
verify_stored_bundles(&install_dir, &bundle_path, pack_ref, Some(pinned_signer))?;
}
Ok(())
}
fn canonical_signer(uri: &str) -> &str {
uri.rsplit_once('@').map_or(uri, |(prefix, _)| prefix)
}
fn verify_stored_bundles(
install_dir: &Path,
bundle_path: &Path,
pack_ref: &str,
pinned_signer: Option<&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 installed_path = entry
.get("installed_path")
.and_then(|v| v.as_str())
.unwrap_or(artifact_name);
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 expected_digest = entry
.get("digest")
.and_then(|v| v.as_str())
.ok_or_else(|| {
nono::NonoError::PackageInstall(format!(
"trust bundle entry missing 'digest' field for '{}' in pack '{}'",
artifact_name, pack_ref
))
})?;
let artifact_path = install_dir.join(validate_bundle_relative_path(
installed_path,
artifact_name,
pack_ref,
)?);
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)),
)?;
let subjects = nono::trust::extract_all_subjects(
&bundle,
Path::new(&format!("{}.bundle", artifact_name)),
)?;
if !subjects
.iter()
.any(|(name, digest)| name == artifact_name && digest == expected_digest)
{
return Err(nono::NonoError::PackageInstall(format!(
"trust bundle for '{}' in pack '{}' does not contain the expected subject digest",
artifact_name, pack_ref
)));
}
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
))
})?;
if let Some(pinned) = pinned_signer {
let identity = nono::trust::extract_signer_identity(&bundle, Path::new(artifact_name))?;
let verified_uri = match &identity {
nono::trust::SignerIdentity::Keyless {
repository,
workflow,
git_ref,
..
} => format!("https://github.com/{repository}/{workflow}@{git_ref}"),
nono::trust::SignerIdentity::Keyed { key_id } => {
format!("keyed:{key_id}")
}
};
if canonical_signer(verified_uri.as_str()) != canonical_signer(pinned) {
return Err(nono::NonoError::PackageVerification {
package: pack_ref.to_string(),
reason: format!(
"signer identity mismatch for '{}': bundle was signed by '{}' \
but lockfile pins '{}'. Reinstall with: nono pull {} --force",
artifact_name, verified_uri, pinned, pack_ref
),
});
}
}
}
Ok(())
}
fn validate_bundle_relative_path<'a>(
installed_path: &'a str,
artifact_name: &str,
pack_ref: &str,
) -> crate::Result<&'a Path> {
let path = Path::new(installed_path);
if installed_path.is_empty() || path.is_absolute() {
return Err(nono::NonoError::PackageInstall(format!(
"trust bundle entry for '{}' in pack '{}' has unsafe installed_path '{}'",
artifact_name, pack_ref, installed_path
)));
}
for component in path.components() {
match component {
std::path::Component::Normal(_) => {}
_ => {
return Err(nono::NonoError::PackageInstall(format!(
"trust bundle entry for '{}' in pack '{}' has unsafe installed_path '{}'",
artifact_name, pack_ref, installed_path
)));
}
}
}
Ok(path)
}
fn expand_bypass_protection_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_bypass_protection_paths(
loaded_profile: Option<&profile::Profile>,
cli_bypass_protection: &[PathBuf],
workdir: &Path,
) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = loaded_profile
.map(|profile| {
profile
.filesystem
.bypass_protection
.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_bypass_protection {
let canonical = expand_bypass_protection_path(path, workdir);
if !paths.contains(&canonical) {
paths.push(canonical);
}
}
paths
}
fn expand_ignored_denial_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());
nono::try_canonicalize(&expanded)
}
fn expand_profile_set_vars(
loaded_profile: Option<&profile::Profile>,
workdir: &Path,
) -> crate::Result<Option<Vec<(String, String)>>> {
let Some(env_config) = loaded_profile.and_then(|profile| profile.environment.as_ref()) else {
return Ok(None);
};
if env_config.set_vars.is_empty() {
return Ok(None);
}
let mut keys: Vec<&String> = env_config.set_vars.keys().collect();
keys.sort();
let mut expanded = Vec::with_capacity(keys.len());
for key in keys {
let Some(value) = env_config.set_vars.get(key) else {
continue;
};
let expanded_value = profile::expand_vars(value, workdir)?
.to_string_lossy()
.into_owned();
expanded.push((key.clone(), expanded_value));
}
Ok(Some(expanded))
}
fn collect_ignored_denial_paths(
loaded_profile: Option<&profile::Profile>,
cli_ignored_denials: &[PathBuf],
workdir: &Path,
) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = loaded_profile
.map(|profile| {
profile
.filesystem
.suppress_save_prompt
.iter()
.filter_map(|template| {
profile::expand_vars(template, workdir)
.ok()
.map(|expanded| nono::try_canonicalize(&expanded))
})
.collect()
})
.unwrap_or_default();
for path in cli_ignored_denials {
let canonical = expand_ignored_denial_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 drafts_dir = config_dir.join("nono").join("profile-drafts");
if !drafts_dir.exists() {
let _ = std::fs::create_dir_all(&drafts_dir);
}
}
let loaded_profile = if let Some(ref profile_name) = args.profile {
let profile = profile::load_profile(profile_name)?;
crate::package_status::enforce_for_active_profile(
Some(profile_name),
options.hook_output_silent,
)?;
let mut packs_to_verify = profile.packs.clone();
if profile::is_registry_ref(profile_name) {
let key = profile_name
.split_once('@')
.map_or(profile_name.as_str(), |(p, _)| p)
.to_string();
if !packs_to_verify.contains(&key) {
packs_to_verify.push(key);
}
}
if args.dry_run {
if !packs_to_verify.is_empty() && !options.hook_output_silent {
eprintln!(
" Skipping pack verification on --dry-run ({} pack(s)); a real run verifies them",
packs_to_verify.len()
);
}
} else {
verify_profile_packs(&packs_to_verify, &profile)?;
if !packs_to_verify.is_empty() && !options.hook_output_silent {
eprintln!(" Verified {} pack(s)", packs_to_verify.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(),
#[cfg(target_os = "linux")]
af_unix_mediation: loaded_profile
.as_ref()
.and_then(|profile| profile.linux.af_unix_mediation)
.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),
bypass_protection_paths: collect_bypass_protection_paths(
loaded_profile.as_ref(),
&args.bypass_protection,
workdir,
),
ignored_denial_paths: collect_ignored_denial_paths(
loaded_profile.as_ref(),
&args.suppress_save_prompt,
workdir,
),
suppressed_system_service_operations: loaded_profile
.as_ref()
.map(|profile| profile.diagnostics.suppress_system_services.clone())
.unwrap_or_default(),
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_env_var_patterns(
&env_config.allow_vars,
"allow_vars",
) {
eprintln!("Warning: {}", err);
}
env_config.allow_vars.clone()
})
}),
denied_env_vars: loaded_profile.as_ref().and_then(|profile| {
profile.environment.as_ref().and_then(|env_config| {
if env_config.deny_vars.is_empty() {
return None;
}
if let Some(err) = crate::exec_strategy::validate_env_var_patterns(
&env_config.deny_vars,
"deny_vars",
) {
eprintln!("Warning: {}", err);
}
Some(env_config.deny_vars.clone())
})
}),
set_vars: expand_profile_set_vars(loaded_profile.as_ref(), workdir)?,
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 profile::{SessionHook, SessionHooks};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use tempfile::tempdir;
fn with_config_env<F, R>(f: F) -> R
where
F: FnOnce(&std::path::Path) -> R,
{
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let tmp = tempdir().expect("tmpdir");
let config_dir = tmp.path().canonicalize().expect("canonicalize");
let _env = crate::test_env::EnvVarGuard::set_all(&[(
"XDG_CONFIG_HOME",
config_dir.to_str().expect("utf8"),
)]);
f(&config_dir)
}
#[test]
fn expand_profile_set_vars_expands_home() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let _env = crate::test_env::EnvVarGuard::set_all(&[("HOME", "/home/tester")]);
let mut profile = profile::Profile::default();
let mut set_vars = std::collections::HashMap::new();
set_vars.insert("RUST_LOG".to_string(), "debug".to_string());
set_vars.insert("CFG".to_string(), "$HOME/.config".to_string());
profile.environment = Some(profile::EnvironmentConfig {
allow_vars: vec![],
deny_vars: vec![],
set_vars,
});
let workdir = Path::new("/tmp/work");
let expanded = expand_profile_set_vars(Some(&profile), workdir)
.expect("expansion should succeed")
.expect("set_vars should be present");
assert_eq!(
expanded,
vec![
("CFG".to_string(), "/home/tester/.config".to_string()),
("RUST_LOG".to_string(), "debug".to_string()),
]
);
}
#[test]
fn expand_profile_set_vars_none_when_absent() {
let profile = profile::Profile::default();
let result = expand_profile_set_vars(Some(&profile), Path::new("/tmp/work"))
.expect("expansion should succeed");
assert!(result.is_none());
}
fn build_pack_with_scripts(
config_dir: &std::path::Path,
ns: &str,
pack_name: &str,
scripts: &[(&str, &str)],
) -> (PathBuf, BTreeMap<String, package::LockedArtifact>) {
let install_dir = config_dir
.join("nono")
.join("packages")
.join(ns)
.join(pack_name);
fs::create_dir_all(&install_dir).expect("create install dir");
let mut artifacts: BTreeMap<String, package::LockedArtifact> = BTreeMap::new();
for (rel_path, content) in scripts {
let full_path = install_dir.join(rel_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).expect("create script dir");
}
fs::write(&full_path, content.as_bytes()).expect("write script");
let digest = Sha256::digest(content.as_bytes());
let sha256 = digest
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
artifacts.insert(
rel_path.to_string(),
package::LockedArtifact {
sha256,
artifact_type: package::ArtifactType::Profile,
},
);
}
(install_dir, artifacts)
}
fn write_test_lockfile(
config_dir: &std::path::Path,
entries: &[(&str, BTreeMap<String, package::LockedArtifact>)],
) {
let lockfile_path = config_dir
.join("nono")
.join("packages")
.join("lockfile.json");
fs::create_dir_all(lockfile_path.parent().expect("parent")).expect("create packages dir");
let mut lockfile = if lockfile_path.exists() {
let content = fs::read_to_string(&lockfile_path).expect("read lockfile");
serde_json::from_str::<package::Lockfile>(&content).expect("parse lockfile")
} else {
package::Lockfile {
lockfile_version: package::LOCKFILE_VERSION,
registry: String::new(),
packages: BTreeMap::new(),
}
};
for (pack_ref, artifacts) in entries {
let pkg = package::LockedPackage {
artifacts: artifacts.clone(),
..package::LockedPackage::default()
};
lockfile.packages.insert(pack_ref.to_string(), pkg);
}
let json = serde_json::to_string_pretty(&lockfile).expect("serialize lockfile");
fs::write(&lockfile_path, format!("{json}\n")).expect("write lockfile");
}
fn make_hook(script: PathBuf, source_pack: Option<&str>) -> SessionHook {
SessionHook {
script,
timeout_secs: None,
source_pack: source_pack
.map(|s| crate::package::parse_package_ref(s).expect("valid pack ref in test")),
}
}
#[test]
fn test_verify_source_pack_not_in_packs_list_is_an_error() {
let hooks = SessionHooks {
before: Some(make_hook(
PathBuf::from("/some/path/script.sh"),
Some("acme/widget"), )),
after: None,
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
let result = verify_profile_packs(&[], &p);
assert!(
result.is_err(),
"source_pack not in packs list must be a hard error"
);
let err = result.expect_err("expected an error from verify_profile_packs");
let msg = err.to_string();
assert!(
msg.contains("/some/path/script.sh"),
"error must reference the offending hook script: {msg}"
);
}
#[test]
fn test_verify_local_profile_hook_not_checked() {
let hooks = SessionHooks {
before: Some(make_hook(
PathBuf::from("/usr/local/bin/my-setup.sh"),
None, )),
after: None,
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
assert!(
verify_profile_packs(&[], &p).is_ok(),
"local profile hooks must not be checked by verify_profile_packs"
);
}
#[test]
fn test_verify_store_pack_hook_in_artifacts_passes() {
let result = with_config_env(|config_dir| {
let (install_dir, artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"widget",
&[("scripts/before.sh", "#!/bin/sh\necho before\n")],
);
write_test_lockfile(config_dir, &[("acme/widget", artifacts)]);
let hooks = SessionHooks {
before: Some(make_hook(
install_dir.join("scripts/before.sh"),
Some("acme/widget"),
)),
after: None,
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
verify_profile_packs(&["acme/widget".to_string()], &p)
});
assert!(
matches!(result, Err(ref e) if e.to_string().contains(".nono-trust.bundle")),
"expected only a missing-trust-bundle error after hook containment passed, got: {result:?}"
);
}
#[test]
fn test_verify_store_pack_hook_not_in_artifacts_fails() {
let result = with_config_env(|config_dir| {
let (install_dir, artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"widget",
&[("scripts/real.sh", "#!/bin/sh\necho real\n")],
);
let unlocked = install_dir.join("scripts/non-existing.sh");
fs::write(&unlocked, "#!/bin/sh\necho unlocked\n").expect("write unlocked");
write_test_lockfile(config_dir, &[("acme/widget", artifacts)]);
let hooks = SessionHooks {
before: Some(make_hook(
install_dir.join("scripts/non-existing.sh"),
Some("acme/widget"),
)),
after: None,
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
verify_profile_packs(&["acme/widget".to_string()], &p)
});
assert!(
result.is_err(),
"hook script not in lockfile artifacts must be rejected"
);
}
#[test]
fn test_verify_store_extends_store_hooks_in_correct_packs_passes() {
let result = with_config_env(|config_dir| {
let (base_install_dir, base_artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"base",
&[("hooks/setup.sh", "#!/bin/sh\necho setup\n")],
);
let (top_install_dir, top_artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"top",
&[("hooks/teardown.sh", "#!/bin/sh\necho teardown\n")],
);
write_test_lockfile(
config_dir,
&[("acme/base", base_artifacts), ("acme/top", top_artifacts)],
);
let hooks = SessionHooks {
before: Some(make_hook(
base_install_dir.join("hooks/setup.sh"),
Some("acme/base"),
)),
after: Some(make_hook(
top_install_dir.join("hooks/teardown.sh"),
Some("acme/top"),
)),
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
verify_profile_packs(&["acme/base".to_string(), "acme/top".to_string()], &p)
});
assert!(
matches!(result, Err(ref e) if e.to_string().contains(".nono-trust.bundle")),
"expected only a missing-trust-bundle error after hook containment passed, got: {result:?}"
);
}
#[test]
fn test_verify_store_extends_store_pack_confusion_fails() {
let result = with_config_env(|config_dir| {
let (base_install_dir, base_artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"base",
&[("hooks/setup.sh", "#!/bin/sh\necho setup\n")],
);
let (_top_install_dir, top_artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"top",
&[("hooks/teardown.sh", "#!/bin/sh\necho teardown\n")],
);
write_test_lockfile(
config_dir,
&[("acme/base", base_artifacts), ("acme/top", top_artifacts)],
);
let hooks = SessionHooks {
before: Some(make_hook(
base_install_dir.join("hooks/setup.sh"),
Some("acme/top"), )),
after: None,
};
let p = profile::Profile {
session_hooks: hooks,
..profile::Profile::default()
};
verify_profile_packs(&["acme/base".to_string(), "acme/top".to_string()], &p)
});
assert!(
result.is_err(),
"hook script not in the claimed pack's artifacts must be rejected"
);
}
#[test]
fn verify_profile_packs_requires_lockfile_entry_for_installed_pack() {
let result = with_config_env(|config_dir| {
let (_, _empty_artifacts) = build_pack_with_scripts(config_dir, "acme", "widget", &[]);
verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default())
});
let err = match result {
Ok(()) => panic!("installed pack without lockfile entry must fail verification"),
Err(err) => err,
};
assert!(
err.to_string().contains("no lockfile entry"),
"unexpected error: {err}"
);
}
#[test]
fn verify_profile_packs_requires_trust_bundle_for_locked_pack() {
let result = with_config_env(|config_dir| {
let artifact_content = r#"{"meta":{"name":"widget"}}"#;
let (_, artifacts) = build_pack_with_scripts(
config_dir,
"acme",
"widget",
&[("package.json", artifact_content)],
);
write_test_lockfile(config_dir, &[("acme/widget", artifacts)]);
verify_profile_packs(&["acme/widget".to_string()], &profile::Profile::default())
});
let err = match result {
Ok(()) => panic!("locked pack without trust bundle must fail verification"),
Err(err) => err,
};
assert!(
err.to_string().contains("missing .nono-trust.bundle"),
"unexpected error: {err}"
);
}
#[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 cli_ignore = workdir.path().join("cli-ignore");
if let Err(err) = fs::create_dir_all(&cli_ignore) {
panic!("failed to create CLI ignore 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]
},
"filesystem": {
"bypass_protection": ["$WORKDIR/.git"],
"suppress_save_prompt": ["$WORKDIR/.copilot/settings.json"]
}
}"#,
) {
panic!("failed to write profile: {err}");
}
let args = SandboxArgs {
profile: Some(profile_path.to_string_lossy().into_owned()),
bypass_protection: vec![cli_override],
suppress_save_prompt: vec![cli_ignore],
..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.bypass_protection_paths,
preflight.bypass_protection_paths
);
assert_eq!(runtime.ignored_denial_paths, preflight.ignored_denial_paths);
assert!(
runtime
.ignored_denial_paths
.contains(&nono::try_canonicalize(
&workdir.path().join(".copilot/settings.json")
))
);
assert!(
runtime
.ignored_denial_paths
.contains(&nono::try_canonicalize(&workdir.path().join("cli-ignore")))
);
assert_eq!(runtime.allowed_env_vars, preflight.allowed_env_vars);
assert_eq!(runtime.denied_env_vars, preflight.denied_env_vars);
assert_eq!(
runtime.loaded_profile.as_ref().map(|profile| {
(
profile.meta.name.clone(),
profile.extends.clone(),
profile.groups.include.clone(),
profile.workdir.access.clone(),
profile.filesystem.allow.clone(),
)
}),
preflight.loaded_profile.as_ref().map(|profile| {
(
profile.meta.name.clone(),
profile.extends.clone(),
profile.groups.include.clone(),
profile.workdir.access.clone(),
profile.filesystem.allow.clone(),
)
})
);
}
}