use crate::cli::{
ProfileCmdArgs, ProfileCommands, ProfileDiffArgs, ProfileGroupsArgs, ProfileGuideArgs,
ProfileInitArgs, ProfileListArgs, ProfilePromoteArgs, ProfileSchemaArgs, ProfileShowArgs,
ProfileValidateArgs,
};
use crate::config::embedded;
use crate::policy::{self, AllowOps, DenyOps, Group};
use crate::profile::{self, Profile, WorkdirAccess};
use crate::theme;
use colored::Colorize;
use nono::{NonoError, Result};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
fn to_json(val: &serde_json::Value) -> Result<String> {
serde_json::to_string_pretty(val)
.map_err(|e| NonoError::ProfileParse(format!("JSON serialization failed: {e}")))
}
fn prefix() -> colored::ColoredString {
let t = theme::current();
theme::fg("nono profile", t.brand).bold()
}
pub fn run_profile(args: ProfileCmdArgs) -> Result<()> {
match args.command {
ProfileCommands::Init(args) => cmd_init(args),
ProfileCommands::List(args) => cmd_list(args),
ProfileCommands::Show(args) => cmd_show(args),
ProfileCommands::Diff(args) => cmd_diff(args),
ProfileCommands::Validate(args) => cmd_validate(args),
ProfileCommands::Promote(args) => cmd_promote(args),
ProfileCommands::Groups(args) => cmd_groups(args),
ProfileCommands::Schema(args) => cmd_schema(args),
ProfileCommands::Guide(args) => cmd_guide(args),
}
}
fn cmd_init(args: ProfileInitArgs) -> Result<()> {
if profile::is_registry_ref(&args.name) {
let short_name = args
.name
.split_once('/')
.map_or(args.name.as_str(), |(_, n)| n);
let suggested = format!("{}-local", short_name);
let extends_target = args.extends.as_deref().unwrap_or(args.name.as_str());
crate::output::print_warning(&format!(
"'{}' is a pack reference, not a profile name. \
Choose a plain name for your profile.",
args.name
));
let t = theme::current();
eprintln!(
" {} nono profile init {} --extends {}",
theme::fg("Try:", t.green).bold(),
suggested,
extends_target
);
return Err(NonoError::Cancelled(String::new()));
}
if !profile::is_valid_profile_name(&args.name) {
return Err(NonoError::ProfileParse(format!(
"Invalid profile name '{}': must be alphanumeric with hyphens, no leading/trailing hyphens",
args.name
)));
}
let output_path = match &args.output {
Some(path) => path.clone(),
None => profile::get_user_profile_path(&args.name)?,
};
if output_path.exists() && !args.force {
return Err(NonoError::ProfileParse(format!(
"Profile file already exists: {}\nUse --force to overwrite",
output_path.display()
)));
}
{
let pol = policy::load_embedded_policy()?;
if pol.profiles.contains_key(args.name.as_str()) {
crate::output::print_warning(&format!(
"Cannot create profile '{}': it conflicts with the built-in '{}' profile.",
args.name, args.name
));
let t = theme::current();
eprintln!(
" {} nono profile init {}-local --extends {}",
theme::fg("Try:", t.green).bold(),
args.name,
args.name
);
return Err(NonoError::Cancelled(String::new()));
}
}
if let Some(ref base) = args.extends
&& !profile_exists(base)
{
return Err(NonoError::ProfileParse(extends_target_not_found_message(
base,
)));
}
if !args.groups.is_empty() {
let pol = policy::load_embedded_policy()?;
for group in &args.groups {
if !pol.groups.contains_key(group.as_str()) {
return Err(NonoError::ProfileParse(format!(
"Unknown security group '{}'. Use `nono profile groups` to list available groups",
group
)));
}
}
}
let skeleton = build_skeleton(&args);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
NonoError::ProfileParse(format!(
"Failed to create directory {}: {}",
parent.display(),
e
))
})?;
}
let json = serde_json::to_string_pretty(&skeleton)
.map_err(|e| NonoError::ProfileParse(format!("JSON serialization failed: {e}")))?;
fs::write(&output_path, format!("{json}\n")).map_err(|e| {
NonoError::ProfileParse(format!(
"Failed to write profile to {}: {}",
output_path.display(),
e
))
})?;
eprintln!(
"{} Created profile at {}",
prefix(),
output_path.display().to_string().bold()
);
eprintln!(
"{} Validate with: nono profile validate {}",
prefix(),
profile_validate_target(&args, &output_path)
);
eprintln!(
"{} For editor autocomplete: nono profile schema -o nono-profile.schema.json",
prefix()
);
Ok(())
}
fn profile_validate_target(args: &ProfileInitArgs, output_path: &Path) -> String {
match profile::get_user_profile_path(&args.name) {
Ok(default_path) if default_path == output_path => args.name.clone(),
_ => output_path.display().to_string(),
}
}
fn build_skeleton(args: &ProfileInitArgs) -> serde_json::Value {
let mut root = serde_json::Map::new();
if let Some(ref base) = args.extends {
root.insert(
"extends".to_string(),
serde_json::Value::String(base.clone()),
);
}
let mut meta = serde_json::Map::new();
meta.insert(
"name".to_string(),
serde_json::Value::String(args.name.clone()),
);
if let Some(ref desc) = args.description {
meta.insert(
"description".to_string(),
serde_json::Value::String(desc.clone()),
);
}
root.insert("meta".to_string(), serde_json::Value::Object(meta));
let mut groups = serde_json::Map::new();
let include: Vec<serde_json::Value> = args
.groups
.iter()
.map(|g| serde_json::Value::String(g.clone()))
.collect();
groups.insert("include".to_string(), serde_json::Value::Array(include));
if args.full {
groups.insert("exclude".to_string(), serde_json::Value::Array(vec![]));
}
root.insert("groups".to_string(), serde_json::Value::Object(groups));
if args.full {
let mut commands = serde_json::Map::new();
commands.insert("allow".to_string(), serde_json::Value::Array(vec![]));
commands.insert("deny".to_string(), serde_json::Value::Array(vec![]));
root.insert("commands".to_string(), serde_json::Value::Object(commands));
}
let mut workdir = serde_json::Map::new();
workdir.insert(
"access".to_string(),
serde_json::Value::String("readwrite".to_string()),
);
root.insert("workdir".to_string(), serde_json::Value::Object(workdir));
let mut filesystem = serde_json::Map::new();
filesystem.insert("allow".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert("read".to_string(), serde_json::Value::Array(vec![]));
if args.full {
filesystem.insert("write".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert("allow_file".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert("read_file".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert("write_file".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert("deny".to_string(), serde_json::Value::Array(vec![]));
filesystem.insert(
"bypass_protection".to_string(),
serde_json::Value::Array(vec![]),
);
filesystem.insert(
"suppress_save_prompt".to_string(),
serde_json::Value::Array(vec![]),
);
}
root.insert(
"filesystem".to_string(),
serde_json::Value::Object(filesystem),
);
if args.full {
let mut network = serde_json::Map::new();
network.insert("block".to_string(), serde_json::Value::Bool(false));
network.insert("allow_domain".to_string(), serde_json::Value::Array(vec![]));
network.insert("credentials".to_string(), serde_json::Value::Array(vec![]));
network.insert("open_port".to_string(), serde_json::Value::Array(vec![]));
network.insert("listen_port".to_string(), serde_json::Value::Array(vec![]));
network.insert(
"custom_credentials".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
root.insert("network".to_string(), serde_json::Value::Object(network));
root.insert(
"env_credentials".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
root.insert(
"hooks".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
let mut rollback = serde_json::Map::new();
rollback.insert(
"exclude_patterns".to_string(),
serde_json::Value::Array(vec![]),
);
rollback.insert(
"exclude_globs".to_string(),
serde_json::Value::Array(vec![]),
);
root.insert("rollback".to_string(), serde_json::Value::Object(rollback));
}
serde_json::Value::Object(root)
}
fn profile_exists(name: &str) -> bool {
if profile::builtin::get_builtin(name).is_some() {
return true;
}
if let Ok(path) = profile::get_user_profile_path(name)
&& path.exists()
{
return true;
}
profile::find_pack_store_profile(name).is_some()
}
fn extends_target_not_found_message(name: &str) -> String {
format!(
"Base profile '{name}' not found (built-in, user, or installed pack). \
If it's provided by a registry pack, run `nono pull <namespace>/<pack>` first."
)
}
fn cmd_schema(args: ProfileSchemaArgs) -> Result<()> {
let schema = embedded::embedded_profile_schema();
match args.output {
Some(path) => {
fs::write(&path, schema).map_err(|e| {
NonoError::ProfileParse(format!(
"Failed to write schema to {}: {}",
path.display(),
e
))
})?;
eprintln!(
"{} Schema written to {}",
prefix(),
path.display().to_string().bold()
);
}
None => {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle
.write_all(schema.as_bytes())
.map_err(|e| NonoError::ProfileParse(format!("Failed to write to stdout: {e}")))?;
}
}
Ok(())
}
fn cmd_guide(_args: ProfileGuideArgs) -> Result<()> {
let guide = embedded::embedded_profile_guide();
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle
.write_all(guide.as_bytes())
.map_err(|e| NonoError::ProfileParse(format!("Failed to write to stdout: {e}")))?;
Ok(())
}
pub(crate) fn cmd_groups(args: ProfileGroupsArgs) -> Result<()> {
let pol = policy::load_embedded_policy()?;
match args.name {
Some(name) => cmd_groups_detail(&pol, &name, args.json),
None => cmd_groups_list(&pol, args.json, args.all_platforms),
}
}
fn cmd_groups_list(pol: &policy::Policy, json: bool, all_platforms: bool) -> Result<()> {
let mut groups: Vec<(&String, &Group)> = pol.groups.iter().collect();
groups.sort_by_key(|(name, _)| name.as_str());
if !all_platforms {
groups.retain(|(_, g)| policy::group_matches_platform(g));
}
if json {
let arr: Vec<serde_json::Value> = groups
.iter()
.map(|(name, g)| {
serde_json::json!({
"name": name,
"description": g.description,
"platform": g.platform.as_deref().unwrap_or("cross-platform"),
"required": g.required,
"allow": count_allow(&g.allow),
"deny": count_deny(&g.deny),
})
})
.collect();
println!("{}", to_json(&serde_json::Value::Array(arr))?);
return Ok(());
}
let t = theme::current();
println!(
"{}: {} groups{}",
prefix(),
groups.len(),
if all_platforms {
" (all platforms)"
} else {
""
}
);
println!();
for (name, group) in &groups {
let platform = group.platform.as_deref().unwrap_or("cross-platform");
let required = if group.required { " required" } else { "" };
println!(
" {:<36} {:<42} {}{}",
theme::fg(name, t.text).bold(),
theme::fg(&group.description, t.subtext),
theme::fg(platform, t.overlay),
theme::fg(required, t.yellow),
);
}
Ok(())
}
fn cmd_groups_detail(pol: &policy::Policy, name: &str, json: bool) -> Result<()> {
let group = pol.groups.get(name).ok_or_else(|| {
NonoError::ProfileParse(format!(
"group '{}' not found in policy.json. Use `nono profile groups` to list available groups",
name
))
})?;
if json {
let val = group_to_json(name, group);
println!("{}", to_json(&val)?);
return Ok(());
}
let t = theme::current();
println!("{}: group '{}'", prefix(), theme::fg(name, t.text).bold());
println!();
println!(
" {} {}",
theme::fg("Description:", t.subtext),
theme::fg(&group.description, t.text)
);
println!(
" {} {}",
theme::fg("Platform:", t.subtext),
theme::fg(
group.platform.as_deref().unwrap_or("cross-platform"),
t.text
)
);
println!(
" {} {}",
theme::fg("Required:", t.subtext),
theme::fg(if group.required { "yes" } else { "no" }, t.text)
);
if let Some(ref allow) = group.allow {
print_path_section("allow.read", &allow.read, t);
print_path_section("allow.write", &allow.write, t);
print_path_section("allow.readwrite", &allow.readwrite, t);
}
if let Some(ref deny) = group.deny {
print_path_section("deny.access", &deny.access, t);
if deny.unlink {
println!();
println!(" {}", theme::fg("deny.unlink:", t.red).bold());
println!(" {}", theme::fg("enabled", t.red));
}
if !deny.commands.is_empty() {
println!();
println!(" {}", theme::fg("deny.commands:", t.red).bold());
for cmd in &deny.commands {
println!(" {}", theme::fg(cmd, t.text));
}
}
}
if let Some(ref pairs) = group.symlink_pairs
&& !pairs.is_empty()
{
println!();
println!(" {}", theme::fg("symlink_pairs:", t.subtext).bold());
let mut sorted: Vec<(&String, &String)> = pairs.iter().collect();
sorted.sort_by_key(|(k, _)| k.as_str());
for (from, to) in sorted {
println!(
" {} -> {}",
theme::fg(from, t.text),
theme::fg(to, t.subtext)
);
}
}
Ok(())
}
fn print_path_section(label: &str, paths: &[String], t: &theme::Theme) {
if paths.is_empty() {
return;
}
let color = if label.starts_with("deny") {
t.red
} else {
t.green
};
println!();
println!(" {}", theme::fg(&format!("{label}:"), color).bold());
for raw in paths {
match policy::expand_path(raw) {
Ok(expanded) => {
let exp_str = expanded.display().to_string();
if exp_str == *raw {
println!(" {}", theme::fg(raw, t.text));
} else {
println!(
" {:<36} -> {}",
theme::fg(raw, t.text),
theme::fg(&exp_str, t.subtext)
);
}
}
Err(_) => {
println!(
" {:<36} -> {}",
theme::fg(raw, t.text),
theme::fg("<expansion failed>", t.red)
);
}
}
}
}
fn count_allow(allow: &Option<AllowOps>) -> serde_json::Value {
match allow {
Some(a) => serde_json::json!({
"read": a.read.len(),
"write": a.write.len(),
"readwrite": a.readwrite.len(),
}),
None => serde_json::json!({}),
}
}
fn count_deny(deny: &Option<DenyOps>) -> serde_json::Value {
match deny {
Some(d) => serde_json::json!({
"access": d.access.len(),
"commands": d.commands.len(),
"unlink": d.unlink,
}),
None => serde_json::json!({}),
}
}
fn group_to_json(name: &str, group: &Group) -> serde_json::Value {
let mut val = serde_json::json!({
"name": name,
"description": group.description,
"platform": group.platform.as_deref().unwrap_or("cross-platform"),
"required": group.required,
});
if let Some(ref allow) = group.allow {
let mut allow_val = serde_json::Map::new();
if !allow.read.is_empty() {
allow_val.insert("read".into(), expand_paths_json(&allow.read));
}
if !allow.write.is_empty() {
allow_val.insert("write".into(), expand_paths_json(&allow.write));
}
if !allow.readwrite.is_empty() {
allow_val.insert("readwrite".into(), expand_paths_json(&allow.readwrite));
}
val["allow"] = serde_json::Value::Object(allow_val);
}
if let Some(ref deny) = group.deny {
let mut deny_val = serde_json::Map::new();
if !deny.access.is_empty() {
deny_val.insert("access".into(), expand_paths_json(&deny.access));
}
if !deny.commands.is_empty() {
deny_val.insert("commands".into(), serde_json::json!(deny.commands));
}
if deny.unlink {
deny_val.insert("unlink".into(), serde_json::json!(true));
}
val["deny"] = serde_json::Value::Object(deny_val);
}
if let Some(ref pairs) = group.symlink_pairs
&& !pairs.is_empty()
{
val["symlink_pairs"] = serde_json::json!(pairs);
}
val
}
fn expand_paths_json(paths: &[String]) -> serde_json::Value {
let arr: Vec<serde_json::Value> = paths
.iter()
.map(|raw| {
let expanded = policy::expand_path(raw)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<expansion failed>".to_string());
serde_json::json!({
"raw": raw,
"expanded": expanded,
})
})
.collect();
serde_json::Value::Array(arr)
}
fn profile_source(name: &str) -> &'static str {
let builtin_names = profile::builtin::list_builtin();
let is_pack = profile::list_pack_store_profiles()
.iter()
.any(|(n, _)| n == name);
if profile::is_user_override(name) {
if is_pack {
"user (overrides pack)"
} else if builtin_names.contains(&name.to_string()) {
"user (overrides built-in)"
} else {
"user"
}
} else if is_pack {
"pack"
} else if builtin_names.contains(&name.to_string()) {
"built-in"
} else {
"user"
}
}
pub(crate) fn cmd_list(args: ProfileListArgs) -> Result<()> {
let builtin_names = profile::builtin::list_builtin();
let all_names = profile::list_profiles();
let pack_profiles: std::collections::HashMap<String, String> =
profile::list_pack_store_profiles().into_iter().collect();
let mut builtin_profiles: Vec<(String, Result<Profile>)> = Vec::new();
let mut user_profiles: Vec<(String, Result<Profile>)> = Vec::new();
let mut pack_entries: Vec<(String, String, Result<Profile>)> = Vec::new();
for name in &all_names {
let p = profile::load_profile_no_migrate(name);
if profile::is_user_override(name) {
user_profiles.push((name.clone(), p));
} else if let Some(pack_ref) = pack_profiles.get(name) {
pack_entries.push((name.clone(), pack_ref.clone(), p));
} else if builtin_names.contains(name) {
builtin_profiles.push((name.clone(), p));
} else {
user_profiles.push((name.clone(), p));
}
}
if args.json {
let format_entry = |name: &str, result: &Result<Profile>| {
let source = profile_source(name);
let extends = profile::load_profile_extends(name).unwrap_or_default();
let pack = pack_profiles.get(name).cloned();
match result {
Ok(p) => serde_json::json!({
"name": name,
"source": source,
"pack": pack,
"description": p.meta.description.as_deref().unwrap_or(""),
"extends": extends,
}),
Err(e) => serde_json::json!({
"name": name,
"source": source,
"pack": pack,
"error": format!("{}", e),
}),
}
};
let arr: Vec<serde_json::Value> = builtin_profiles
.iter()
.map(|(n, p)| format_entry(n, p))
.chain(pack_entries.iter().map(|(n, _, p)| format_entry(n, p)))
.chain(user_profiles.iter().map(|(n, p)| format_entry(n, p)))
.collect();
println!("{}", to_json(&serde_json::Value::Array(arr))?);
return Ok(());
}
let t = theme::current();
let total = builtin_profiles.len() + pack_entries.len() + user_profiles.len();
println!("{}: {} profiles", prefix(), total);
if !builtin_profiles.is_empty() {
println!();
println!(" {}", theme::fg("Built-in:", t.subtext).bold());
for (name, result) in &builtin_profiles {
print_profile_line(name, result, t);
}
}
if !pack_entries.is_empty() {
println!();
println!(" {}", theme::fg("Packages:", t.subtext).bold());
for (name, pack_ref, result) in &pack_entries {
print_pack_profile_line(name, pack_ref, result, t);
}
}
if !user_profiles.is_empty() {
println!();
println!(
" {}",
theme::fg("User (~/.config/nono/profiles/):", t.subtext).bold()
);
for (name, result) in &user_profiles {
print_profile_line(name, result, t);
}
}
Ok(())
}
fn print_pack_profile_line(name: &str, pack_ref: &str, result: &Result<Profile>, t: &theme::Theme) {
match result {
Ok(p) => {
let desc = p.meta.description.as_deref().unwrap_or("").to_string();
let pack_label = format!("from {pack_ref}");
println!(
" {:<16} {:<42} {}",
theme::fg(name, t.text).bold(),
theme::fg(&desc, t.subtext),
theme::fg(&pack_label, t.overlay),
);
}
Err(e) => {
println!(
" {:<16} {}",
theme::fg(name, t.text).bold(),
theme::fg(&format!("[error: {}]", e), t.red),
);
}
}
}
fn print_profile_line(name: &str, result: &Result<Profile>, t: &theme::Theme) {
match result {
Ok(p) => {
let desc = p.meta.description.as_deref().unwrap_or("").to_string();
let extends = profile::load_profile_extends(name)
.map(|v| format!("extends {}", v.join(", ")))
.unwrap_or_default();
println!(
" {:<16} {:<42} {}",
theme::fg(name, t.text).bold(),
theme::fg(&desc, t.subtext),
theme::fg(&extends, t.overlay),
);
}
Err(e) => {
println!(
" {:<16} {}",
theme::fg(name, t.text).bold(),
theme::fg(&format!("[error: {}]", e), t.red),
);
}
}
}
#[allow(deprecated)] pub(crate) fn cmd_show(args: ProfileShowArgs) -> Result<()> {
let raw_extends = profile::load_profile_extends(&args.profile);
let profile = profile::load_profile_no_migrate(&args.profile)?;
if matches!(args.format, Some(crate::cli::ProfileShowFormat::Manifest)) {
let workdir = std::env::current_dir().map_err(|e| {
NonoError::ConfigParse(format!("cannot determine working directory: {e}"))
})?;
let manifest = resolve_to_manifest(&profile, &workdir)?;
let json = manifest.to_json()?;
println!("{json}");
return Ok(());
}
if args.json {
let val = profile_to_json(&args.profile, &profile, &raw_extends);
println!("{}", to_json(&val)?);
return Ok(());
}
let t = theme::current();
println!(
"{}: profile '{}'",
prefix(),
theme::fg(&args.profile, t.text).bold()
);
if let Some(ref desc) = profile.meta.description {
println!();
println!(
" {} {}",
theme::fg("Description:", t.subtext),
theme::fg(desc, t.text)
);
}
if let Some(ref extends) = raw_extends {
println!(
" {} {}",
theme::fg("Extends:", t.subtext),
theme::fg(&extends.join(", "), t.text)
);
}
if !profile.groups.include.is_empty() {
println!();
println!(" {}", theme::fg("Security groups:", t.subtext).bold());
for g in &profile.groups.include {
println!(" {}", theme::fg(g, t.text));
}
}
if !profile.groups.exclude.is_empty() {
println!();
println!(
" {}",
theme::fg("Excluded security groups:", t.subtext).bold()
);
for g in &profile.groups.exclude {
println!(" {}", theme::fg(g, t.yellow));
}
}
if !profile.commands.allow.is_empty() {
println!();
println!(
" {}",
theme::fg("Allowed commands (deprecated, startup-only):", t.subtext).bold()
);
for cmd in &profile.commands.allow {
println!(" {}", theme::fg(cmd, t.text));
}
}
if !profile.commands.deny.is_empty() {
println!();
println!(
" {}",
theme::fg("Denied commands (deprecated, startup-only):", t.subtext).bold()
);
for cmd in &profile.commands.deny {
println!(" {}", theme::fg(cmd, t.yellow));
}
}
if let Some(mode) = &profile.security.signal_mode {
println!(" {} {:?}", theme::fg("Signal mode:", t.subtext), mode);
}
if let Some(mode) = &profile.security.process_info_mode {
println!(" {} {:?}", theme::fg("Process info:", t.subtext), mode);
}
if let Some(mode) = &profile.security.ipc_mode {
println!(" {} {:?}", theme::fg("IPC mode:", t.subtext), mode);
}
if let Some(elev) = profile.security.capability_elevation {
println!(
" {} {}",
theme::fg("Capability elevation:", t.subtext),
theme::fg(if elev { "enabled" } else { "disabled" }, t.text)
);
}
if let Some(policy) = profile.security.wsl2_proxy_policy {
println!(
" {} {}",
theme::fg("WSL2 proxy policy:", t.subtext),
theme::fg(&format!("{policy:?}"), t.text)
);
}
if let Some(mode) = profile.linux.af_unix_mediation {
println!(
" {} {}",
theme::fg("Linux AF_UNIX mediation:", t.subtext),
theme::fg(&format!("{mode:?}"), t.text)
);
}
let fs = &profile.filesystem;
let has_fs = !fs.allow.is_empty()
|| !fs.read.is_empty()
|| !fs.write.is_empty()
|| !fs.allow_file.is_empty()
|| !fs.read_file.is_empty()
|| !fs.write_file.is_empty()
|| !fs.deny.is_empty()
|| !fs.bypass_protection.is_empty()
|| !fs.suppress_save_prompt.is_empty();
if has_fs {
println!();
println!(" {}", theme::fg("Filesystem:", t.subtext).bold());
print_fs_paths("allow (r+w)", &fs.allow, t, args.raw);
print_fs_paths("read", &fs.read, t, args.raw);
print_fs_paths("write", &fs.write, t, args.raw);
print_fs_paths("allow_file (r+w)", &fs.allow_file, t, args.raw);
print_fs_paths("read_file", &fs.read_file, t, args.raw);
print_fs_paths("write_file", &fs.write_file, t, args.raw);
print_fs_paths("deny", &fs.deny, t, args.raw);
print_fs_paths(
"suppress_save_prompt",
&fs.suppress_save_prompt,
t,
args.raw,
);
if !fs.bypass_protection.is_empty() {
println!(
" {}: {}",
theme::fg("bypass_protection", t.yellow),
fs.bypass_protection.join(", ")
);
}
}
let net = &profile.network;
let has_net = net.block
|| net.resolved_network_profile().is_some()
|| !net.allow_domain.is_empty()
|| !net.resolved_credentials().is_empty()
|| !net.open_port.is_empty()
|| !net.listen_port.is_empty()
|| net.upstream_proxy.is_some()
|| !net.upstream_bypass.is_empty();
if has_net {
println!();
println!(" {}", theme::fg("Network:", t.subtext).bold());
if net.block {
println!(" {}", theme::fg("network blocked", t.red));
}
if let Some(np) = net.resolved_network_profile() {
println!(
" {}: {}",
theme::fg("network_profile", t.subtext),
theme::fg(np, t.text)
);
}
if !net.allow_domain.is_empty() {
println!(
" {}: {}",
theme::fg("allow_domain", t.subtext),
net.allow_domain.join(", ")
);
}
if !net.resolved_credentials().is_empty() {
println!(
" {}: {}",
theme::fg("credentials", t.subtext),
net.resolved_credentials().join(", ")
);
}
if !net.open_port.is_empty() {
let ports: Vec<String> = net.open_port.iter().map(|p| p.to_string()).collect();
println!(
" {}: {}",
theme::fg("open_port", t.subtext),
ports.join(", ")
);
}
if !net.listen_port.is_empty() {
let ports: Vec<String> = net.listen_port.iter().map(|p| p.to_string()).collect();
println!(
" {}: {}",
theme::fg("listen_port", t.subtext),
ports.join(", ")
);
}
if let Some(ref ep) = net.upstream_proxy {
println!(
" {}: {}",
theme::fg("upstream_proxy", t.subtext),
theme::fg(ep, t.text)
);
}
if !net.upstream_bypass.is_empty() {
println!(
" {}: {}",
theme::fg("upstream_bypass", t.subtext),
net.upstream_bypass.join(", ")
);
}
}
if profile.workdir.access != WorkdirAccess::None {
println!();
println!(
" {} {:?}",
theme::fg("Workdir access:", t.subtext).bold(),
profile.workdir.access
);
}
let rb = &profile.rollback;
if !rb.exclude_patterns.is_empty() || !rb.exclude_globs.is_empty() {
println!();
println!(" {}", theme::fg("Rollback exclusions:", t.subtext).bold());
for p in &rb.exclude_patterns {
println!(" {}", theme::fg(p, t.text));
}
for g in &rb.exclude_globs {
println!(
" {} {}",
theme::fg("glob:", t.overlay),
theme::fg(g, t.text)
);
}
}
if let Some(ref urls) = profile.open_urls {
println!();
println!(" {}", theme::fg("Open URLs:", t.subtext).bold());
if urls.allow_localhost {
println!(" {}", theme::fg("localhost allowed", t.text));
}
for origin in &urls.allow_origins {
println!(" {}", theme::fg(origin, t.text));
}
}
if !profile.unsafe_macos_seatbelt_rules.is_empty() {
println!();
println!(
" {}",
theme::fg(
"Raw Seatbelt rules (unsafe_macos_seatbelt_rules):",
t.yellow
)
.bold()
);
for rule in &profile.unsafe_macos_seatbelt_rules {
println!(" {}", theme::fg(rule, t.text));
}
}
Ok(())
}
fn print_fs_paths(label: &str, paths: &[String], t: &theme::Theme, raw: bool) {
if paths.is_empty() {
return;
}
println!(" {}:", theme::fg(label, t.subtext));
for p in paths {
if raw {
println!(" {}", theme::fg(p, t.text));
} else {
match policy::expand_path(p) {
Ok(expanded) => {
let exp_str = expanded.display().to_string();
if exp_str == *p {
println!(" {}", theme::fg(p, t.text));
} else {
println!(
" {:<36} -> {}",
theme::fg(p, t.text),
theme::fg(&exp_str, t.subtext)
);
}
}
Err(_) => {
println!(" {}", theme::fg(p, t.text));
}
}
}
}
}
#[allow(deprecated)] fn profile_to_json(
_name: &str,
profile: &Profile,
raw_extends: &Option<Vec<String>>,
) -> serde_json::Value {
let mut val = serde_json::json!({
"name": profile.meta.name,
"description": profile.meta.description.as_deref().unwrap_or(""),
"extends": raw_extends.as_ref().map(|v| serde_json::json!(v)).unwrap_or(serde_json::Value::Null),
});
let mut security = serde_json::Map::new();
if let Some(v) = profile.security.signal_mode {
security.insert("signal_mode".into(), serde_json::json!(v));
}
if let Some(v) = profile.security.process_info_mode {
security.insert("process_info_mode".into(), serde_json::json!(v));
}
if let Some(v) = profile.security.ipc_mode {
security.insert("ipc_mode".into(), serde_json::json!(v));
}
security.insert(
"capability_elevation".into(),
serde_json::json!(profile.security.capability_elevation),
);
if let Some(v) = profile.security.wsl2_proxy_policy {
security.insert("wsl2_proxy_policy".into(), serde_json::json!(v));
}
val["security"] = serde_json::Value::Object(security);
if let Some(v) = profile.linux.af_unix_mediation {
val["linux"] = serde_json::json!({ "af_unix_mediation": v });
}
val["filesystem"] = serde_json::json!({
"allow": profile.filesystem.allow,
"read": profile.filesystem.read,
"write": profile.filesystem.write,
"allow_file": profile.filesystem.allow_file,
"read_file": profile.filesystem.read_file,
"write_file": profile.filesystem.write_file,
"deny": profile.filesystem.deny,
"bypass_protection": profile.filesystem.bypass_protection,
"suppress_save_prompt": profile.filesystem.suppress_save_prompt,
});
if !profile.groups.include.is_empty() || !profile.groups.exclude.is_empty() {
val["groups"] = serde_json::json!({
"include": profile.groups.include,
"exclude": profile.groups.exclude,
});
}
if !profile.commands.allow.is_empty() || !profile.commands.deny.is_empty() {
val["commands"] = serde_json::json!({
"allow": profile.commands.allow,
"deny": profile.commands.deny,
});
}
val["network"] = serde_json::json!({
"block": profile.network.block,
"network_profile": profile.network.resolved_network_profile(),
"allow_domain": profile.network.allow_domain,
"credentials": profile.network.resolved_credentials(),
"open_port": profile.network.open_port,
"listen_port": profile.network.listen_port,
"upstream_proxy": profile.network.upstream_proxy,
"upstream_bypass": profile.network.upstream_bypass,
});
val["workdir"] = serde_json::json!({
"access": profile.workdir.access,
});
val["rollback"] = serde_json::json!({
"exclude_patterns": profile.rollback.exclude_patterns,
"exclude_globs": profile.rollback.exclude_globs,
});
if !profile.env_credentials.mappings.is_empty() {
val["env_credentials"] = serde_json::json!(profile.env_credentials.mappings);
}
if !profile.hooks.hooks.is_empty() {
let hooks: serde_json::Map<String, serde_json::Value> = profile
.hooks
.hooks
.iter()
.map(|(k, v)| {
(
k.clone(),
serde_json::json!({
"event": v.event,
"matcher": v.matcher,
"script": v.script,
}),
)
})
.collect();
val["hooks"] = serde_json::Value::Object(hooks);
}
if let Some(ref urls) = profile.open_urls {
val["open_urls"] = serde_json::json!({
"allow_origins": urls.allow_origins,
"allow_localhost": urls.allow_localhost,
});
}
if let Some(als) = profile.allow_launch_services {
val["allow_launch_services"] = serde_json::json!(als);
}
if let Some(ag) = profile.allow_gpu {
val["allow_gpu"] = serde_json::json!(ag);
}
if !profile.unsafe_macos_seatbelt_rules.is_empty() {
val["unsafe_macos_seatbelt_rules"] = serde_json::json!(profile.unsafe_macos_seatbelt_rules);
}
val
}
#[allow(deprecated)] pub(crate) fn cmd_diff(args: ProfileDiffArgs) -> Result<()> {
let p1 = profile::load_profile_no_migrate(&args.profile1)?;
let p2 = profile::load_profile_no_migrate(&args.profile2)?;
if args.json {
let val = diff_to_json(&args.profile1, &args.profile2, &p1, &p2);
println!("{}", to_json(&val)?);
return Ok(());
}
let t = theme::current();
println!(
"{}: diff '{}' vs '{}'",
prefix(),
theme::fg(&args.profile1, t.text).bold(),
theme::fg(&args.profile2, t.text).bold()
);
let mut any_diff = false;
let g1: BTreeSet<&str> = p1.groups.include.iter().map(|s| s.as_str()).collect();
let g2: BTreeSet<&str> = p2.groups.include.iter().map(|s| s.as_str()).collect();
let added_groups: BTreeSet<&&str> = g2.difference(&g1).collect();
let removed_groups: BTreeSet<&&str> = g1.difference(&g2).collect();
if !added_groups.is_empty() || !removed_groups.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Groups", t.subtext).bold());
for g in &removed_groups {
println!(" {} {}", theme::fg("-", t.red), theme::fg(g, t.red));
}
for g in &added_groups {
println!(" {} {}", theme::fg("+", t.green), theme::fg(g, t.green));
}
}
let fs_diffs = diff_string_vecs(&[
("allow", &p1.filesystem.allow, &p2.filesystem.allow),
("read", &p1.filesystem.read, &p2.filesystem.read),
("write", &p1.filesystem.write, &p2.filesystem.write),
(
"allow_file",
&p1.filesystem.allow_file,
&p2.filesystem.allow_file,
),
(
"read_file",
&p1.filesystem.read_file,
&p2.filesystem.read_file,
),
(
"write_file",
&p1.filesystem.write_file,
&p2.filesystem.write_file,
),
]);
if !fs_diffs.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Filesystem", t.subtext).bold());
for (label, sign, path) in &fs_diffs {
let color = if *sign == "+" { t.green } else { t.red };
println!(
" {} {} {}",
theme::fg(sign, color),
theme::fg(label, t.subtext),
theme::fg(path, color)
);
}
}
let pp_diffs = diff_string_vecs(&[
("groups.exclude", &p1.groups.exclude, &p2.groups.exclude),
("filesystem.deny", &p1.filesystem.deny, &p2.filesystem.deny),
(
"filesystem.bypass_protection",
&p1.filesystem.bypass_protection,
&p2.filesystem.bypass_protection,
),
(
"filesystem.suppress_save_prompt",
&p1.filesystem.suppress_save_prompt,
&p2.filesystem.suppress_save_prompt,
),
("commands.deny", &p1.commands.deny, &p2.commands.deny),
]);
if !pp_diffs.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Composition", t.subtext).bold());
for (label, sign, val) in &pp_diffs {
let color = if *sign == "+" { t.green } else { t.red };
println!(
" {} {} {}",
theme::fg(sign, color),
theme::fg(label, t.subtext),
theme::fg(val, color)
);
}
}
any_diff |= diff_scalar_option(
"capability_elevation",
&p1.security.capability_elevation.map(|v| format!("{v}")),
&p2.security.capability_elevation.map(|v| format!("{v}")),
t,
);
any_diff |= diff_scalar_option(
"wsl2_proxy_policy",
&p1.security.wsl2_proxy_policy.map(|v| format!("{v:?}")),
&p2.security.wsl2_proxy_policy.map(|v| format!("{v:?}")),
t,
);
any_diff |= diff_scalar_option(
"linux.af_unix_mediation",
&p1.linux.af_unix_mediation.map(|v| format!("{v:?}")),
&p2.linux.af_unix_mediation.map(|v| format!("{v:?}")),
t,
);
any_diff |= diff_scalar_option(
"signal_mode",
&p1.security.signal_mode.map(|v| format!("{v:?}")),
&p2.security.signal_mode.map(|v| format!("{v:?}")),
t,
);
any_diff |= diff_scalar_option(
"process_info_mode",
&p1.security.process_info_mode.map(|v| format!("{v:?}")),
&p2.security.process_info_mode.map(|v| format!("{v:?}")),
t,
);
any_diff |= diff_scalar_option(
"ipc_mode",
&p1.security.ipc_mode.map(|v| format!("{v:?}")),
&p2.security.ipc_mode.map(|v| format!("{v:?}")),
t,
);
let mut net_diffs: Vec<(String, String)> = Vec::new();
if p1.network.block != p2.network.block {
net_diffs.push((
format!("- block: {}", p1.network.block),
format!("+ block: {}", p2.network.block),
));
}
let np1 = p1.network.resolved_network_profile().unwrap_or("");
let np2 = p2.network.resolved_network_profile().unwrap_or("");
if np1 != np2 {
if !np1.is_empty() {
net_diffs.push((format!("- network_profile: {np1}"), String::new()));
}
if !np2.is_empty() {
net_diffs.push((String::new(), format!("+ network_profile: {np2}")));
}
}
let net_vec_diffs = diff_string_vecs(&[
(
"allow_domain",
&p1.network.allow_domain,
&p2.network.allow_domain,
),
(
"credentials",
p1.network.resolved_credentials(),
p2.network.resolved_credentials(),
),
(
"upstream_bypass",
&p1.network.upstream_bypass,
&p2.network.upstream_bypass,
),
]);
let port1: Vec<String> = p1.network.open_port.iter().map(|p| p.to_string()).collect();
let port2: Vec<String> = p2.network.open_port.iter().map(|p| p.to_string()).collect();
let port_diffs = diff_string_vecs(&[("open_port", &port1, &port2)]);
let listen1: Vec<String> = p1
.network
.listen_port
.iter()
.map(|p| p.to_string())
.collect();
let listen2: Vec<String> = p2
.network
.listen_port
.iter()
.map(|p| p.to_string())
.collect();
let listen_diffs = diff_string_vecs(&[("listen_port", &listen1, &listen2)]);
if !net_diffs.is_empty()
|| !net_vec_diffs.is_empty()
|| !port_diffs.is_empty()
|| !listen_diffs.is_empty()
{
any_diff = true;
println!();
println!(" {}:", theme::fg("Network", t.subtext).bold());
for (rem, add) in &net_diffs {
if !rem.is_empty() {
println!(" {}", theme::fg(rem, t.red));
}
if !add.is_empty() {
println!(" {}", theme::fg(add, t.green));
}
}
for (label, sign, val) in net_vec_diffs
.iter()
.chain(port_diffs.iter())
.chain(listen_diffs.iter())
{
let color = if *sign == "+" { t.green } else { t.red };
println!(
" {} {} {}",
theme::fg(sign, color),
theme::fg(label, t.subtext),
theme::fg(val, color)
);
}
}
any_diff |= diff_scalar_option(
"upstream_proxy",
&p1.network.upstream_proxy,
&p2.network.upstream_proxy,
t,
);
if p1.workdir.access != p2.workdir.access {
any_diff = true;
println!();
println!(" {}:", theme::fg("Workdir", t.subtext).bold());
println!(
" {}",
theme::fg(&format!("- access: {:?}", p1.workdir.access), t.red)
);
println!(
" {}",
theme::fg(&format!("+ access: {:?}", p2.workdir.access), t.green)
);
}
let cmd1: BTreeSet<&str> = p1.commands.allow.iter().map(|s| s.as_str()).collect();
let cmd2: BTreeSet<&str> = p2.commands.allow.iter().map(|s| s.as_str()).collect();
let added_cmds: BTreeSet<&&str> = cmd2.difference(&cmd1).collect();
let removed_cmds: BTreeSet<&&str> = cmd1.difference(&cmd2).collect();
if !added_cmds.is_empty() || !removed_cmds.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Allowed commands", t.subtext).bold());
for c in &removed_cmds {
println!(" {} {}", theme::fg("-", t.red), theme::fg(c, t.red));
}
for c in &added_cmds {
println!(" {} {}", theme::fg("+", t.green), theme::fg(c, t.green));
}
}
let rb_diffs = diff_string_vecs(&[
(
"exclude_patterns",
&p1.rollback.exclude_patterns,
&p2.rollback.exclude_patterns,
),
(
"exclude_globs",
&p1.rollback.exclude_globs,
&p2.rollback.exclude_globs,
),
]);
if !rb_diffs.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Rollback", t.subtext).bold());
for (label, sign, val) in &rb_diffs {
let color = if *sign == "+" { t.green } else { t.red };
println!(
" {} {} {}",
theme::fg(sign, color),
theme::fg(label, t.subtext),
theme::fg(val, color)
);
}
}
let ou1_origins: Vec<String> = p1
.open_urls
.as_ref()
.map(|u| u.allow_origins.clone())
.unwrap_or_default();
let ou2_origins: Vec<String> = p2
.open_urls
.as_ref()
.map(|u| u.allow_origins.clone())
.unwrap_or_default();
let ou_diffs = diff_string_vecs(&[("allow_origins", &ou1_origins, &ou2_origins)]);
let ou1_localhost = p1.open_urls.as_ref().is_some_and(|u| u.allow_localhost);
let ou2_localhost = p2.open_urls.as_ref().is_some_and(|u| u.allow_localhost);
if !ou_diffs.is_empty() || ou1_localhost != ou2_localhost {
any_diff = true;
println!();
println!(" {}:", theme::fg("Open URLs", t.subtext).bold());
for (label, sign, val) in &ou_diffs {
let color = if *sign == "+" { t.green } else { t.red };
println!(
" {} {} {}",
theme::fg(sign, color),
theme::fg(label, t.subtext),
theme::fg(val, color)
);
}
if ou1_localhost != ou2_localhost {
println!(
" {}",
theme::fg(&format!("- allow_localhost: {ou1_localhost}"), t.red)
);
println!(
" {}",
theme::fg(&format!("+ allow_localhost: {ou2_localhost}"), t.green)
);
}
}
any_diff |= diff_scalar_option(
"allow_launch_services",
&p1.allow_launch_services.map(|v| format!("{v}")),
&p2.allow_launch_services.map(|v| format!("{v}")),
t,
);
any_diff |= diff_scalar_option(
"allow_gpu",
&p1.allow_gpu.map(|v| format!("{v}")),
&p2.allow_gpu.map(|v| format!("{v}")),
t,
);
let ec1: BTreeSet<(&String, &String)> = p1.env_credentials.mappings.iter().collect();
let ec2: BTreeSet<(&String, &String)> = p2.env_credentials.mappings.iter().collect();
let ec_added: BTreeSet<&(&String, &String)> = ec2.difference(&ec1).collect();
let ec_removed: BTreeSet<&(&String, &String)> = ec1.difference(&ec2).collect();
if !ec_added.is_empty() || !ec_removed.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Env credentials", t.subtext).bold());
for (k, v) in &ec_removed {
println!(
" {} {} -> {}",
theme::fg("-", t.red),
theme::fg(k, t.red),
theme::fg(v, t.red)
);
}
for (k, v) in &ec_added {
println!(
" {} {} -> {}",
theme::fg("+", t.green),
theme::fg(k, t.green),
theme::fg(v, t.green)
);
}
}
let h1: BTreeSet<&String> = p1.hooks.hooks.keys().collect();
let h2: BTreeSet<&String> = p2.hooks.hooks.keys().collect();
let hooks_added: BTreeSet<&&String> = h2.difference(&h1).collect();
let hooks_removed: BTreeSet<&&String> = h1.difference(&h2).collect();
let hooks_changed: Vec<&String> = h1
.intersection(&h2)
.filter(|k| {
let a = &p1.hooks.hooks[**k];
let b = &p2.hooks.hooks[**k];
a.event != b.event || a.matcher != b.matcher || a.script != b.script
})
.copied()
.collect();
if !hooks_added.is_empty() || !hooks_removed.is_empty() || !hooks_changed.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Hooks", t.subtext).bold());
for h in &hooks_removed {
println!(" {} {}", theme::fg("-", t.red), theme::fg(h, t.red));
}
for h in &hooks_added {
println!(" {} {}", theme::fg("+", t.green), theme::fg(h, t.green));
}
for h in &hooks_changed {
println!(
" {} {} (changed)",
theme::fg("~", t.yellow),
theme::fg(h, t.yellow)
);
}
}
let cc1: BTreeSet<&String> = p1.network.custom_credentials.keys().collect();
let cc2: BTreeSet<&String> = p2.network.custom_credentials.keys().collect();
let cc_added: BTreeSet<&&String> = cc2.difference(&cc1).collect();
let cc_removed: BTreeSet<&&String> = cc1.difference(&cc2).collect();
let cc_changed: Vec<&String> = cc1
.intersection(&cc2)
.filter(|k| p1.network.custom_credentials[**k] != p2.network.custom_credentials[**k])
.copied()
.collect();
if !cc_added.is_empty() || !cc_removed.is_empty() || !cc_changed.is_empty() {
any_diff = true;
println!();
println!(" {}:", theme::fg("Custom credentials", t.subtext).bold());
for c in &cc_removed {
println!(" {} {}", theme::fg("-", t.red), theme::fg(c, t.red));
}
for c in &cc_added {
println!(" {} {}", theme::fg("+", t.green), theme::fg(c, t.green));
}
for c in &cc_changed {
let old = &p1.network.custom_credentials[*c];
let new = &p2.network.custom_credentials[*c];
println!(
" {} {} (changed)",
theme::fg("~", t.yellow),
theme::fg(c, t.yellow)
);
if old.upstream != new.upstream {
println!(
" {} upstream: {}",
theme::fg("-", t.red),
theme::fg(&old.upstream, t.red)
);
println!(
" {} upstream: {}",
theme::fg("+", t.green),
theme::fg(&new.upstream, t.green)
);
}
if old.credential_key != new.credential_key {
let old_key = old.credential_key.as_deref().unwrap_or("<none>");
let new_key = new.credential_key.as_deref().unwrap_or("<none>");
println!(
" {} credential_key: {}",
theme::fg("-", t.red),
theme::fg(old_key, t.red)
);
println!(
" {} credential_key: {}",
theme::fg("+", t.green),
theme::fg(new_key, t.green)
);
}
if old.inject_mode != new.inject_mode {
println!(
" {} inject_mode: {:?}",
theme::fg("-", t.red),
old.inject_mode
);
println!(
" {} inject_mode: {:?}",
theme::fg("+", t.green),
new.inject_mode
);
}
if old.inject_header != new.inject_header {
println!(
" {} inject_header: {}",
theme::fg("-", t.red),
theme::fg(&old.inject_header, t.red)
);
println!(
" {} inject_header: {}",
theme::fg("+", t.green),
theme::fg(&new.inject_header, t.green)
);
}
if old.credential_format != new.credential_format {
println!(
" {} credential_format: {}",
theme::fg("-", t.red),
theme::fg(&credential_format_diff_label(&old.credential_format), t.red)
);
println!(
" {} credential_format: {}",
theme::fg("+", t.green),
theme::fg(
&credential_format_diff_label(&new.credential_format),
t.green
)
);
}
if old.path_pattern != new.path_pattern {
println!(
" {} path_pattern: {:?}",
theme::fg("-", t.red),
old.path_pattern
);
println!(
" {} path_pattern: {:?}",
theme::fg("+", t.green),
new.path_pattern
);
}
if old.path_replacement != new.path_replacement {
println!(
" {} path_replacement: {:?}",
theme::fg("-", t.red),
old.path_replacement
);
println!(
" {} path_replacement: {:?}",
theme::fg("+", t.green),
new.path_replacement
);
}
if old.query_param_name != new.query_param_name {
println!(
" {} query_param_name: {:?}",
theme::fg("-", t.red),
old.query_param_name
);
println!(
" {} query_param_name: {:?}",
theme::fg("+", t.green),
new.query_param_name
);
}
if old.env_var != new.env_var {
println!(" {} env_var: {:?}", theme::fg("-", t.red), old.env_var);
println!(
" {} env_var: {:?}",
theme::fg("+", t.green),
new.env_var
);
}
}
}
if !any_diff {
println!();
println!(" {}", theme::fg("(no differences)", t.subtext));
}
Ok(())
}
fn credential_format_diff_label(f: &Option<String>) -> String {
match f {
None => "(default)".to_string(),
Some(s) => s.clone(),
}
}
fn diff_scalar_option(
label: &str,
v1: &Option<String>,
v2: &Option<String>,
t: &theme::Theme,
) -> bool {
if v1 == v2 {
return false;
}
println!();
println!(" {}:", theme::fg(label, t.subtext).bold());
if let Some(old) = v1 {
println!(" {}", theme::fg(&format!("- {old}"), t.red));
}
if let Some(new) = v2 {
println!(" {}", theme::fg(&format!("+ {new}"), t.green));
}
true
}
fn diff_string_vecs<'a>(
pairs: &[(&'a str, &[String], &[String])],
) -> Vec<(&'a str, &'static str, String)> {
let mut result = Vec::new();
for (label, v1, v2) in pairs {
let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect();
let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect();
for removed in s1.difference(&s2) {
result.push((*label, "-", removed.to_string()));
}
for added in s2.difference(&s1) {
result.push((*label, "+", added.to_string()));
}
}
result
}
#[allow(deprecated)] fn diff_to_json(name1: &str, name2: &str, p1: &Profile, p2: &Profile) -> serde_json::Value {
let g1: BTreeSet<&str> = p1.groups.include.iter().map(|s| s.as_str()).collect();
let g2: BTreeSet<&str> = p2.groups.include.iter().map(|s| s.as_str()).collect();
let groups_added: Vec<&str> = g2.difference(&g1).copied().collect();
let groups_removed: Vec<&str> = g1.difference(&g2).copied().collect();
let diff_vec = |v1: &[String], v2: &[String]| -> serde_json::Value {
let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect();
let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect();
let added: Vec<&str> = s2.difference(&s1).copied().collect();
let removed: Vec<&str> = s1.difference(&s2).copied().collect();
serde_json::json!({ "added": added, "removed": removed })
};
let ou1 = p1.open_urls.as_ref();
let ou2 = p2.open_urls.as_ref();
serde_json::json!({
"profile1": name1,
"profile2": name2,
"groups": {
"added": groups_added,
"removed": groups_removed,
},
"commands": {
"allow": diff_vec(&p1.commands.allow, &p2.commands.allow),
"deny": diff_vec(&p1.commands.deny, &p2.commands.deny),
},
"capability_elevation": {
"profile1": p1.security.capability_elevation,
"profile2": p2.security.capability_elevation,
"changed": p1.security.capability_elevation != p2.security.capability_elevation,
},
"wsl2_proxy_policy": {
"profile1": p1.security.wsl2_proxy_policy,
"profile2": p2.security.wsl2_proxy_policy,
"changed": p1.security.wsl2_proxy_policy != p2.security.wsl2_proxy_policy,
},
"linux": {
"af_unix_mediation": {
"profile1": p1.linux.af_unix_mediation,
"profile2": p2.linux.af_unix_mediation,
"changed": p1.linux.af_unix_mediation != p2.linux.af_unix_mediation,
}
},
"filesystem": diff_fs_json(&p1.filesystem, &p2.filesystem),
"workdir": {
"profile1": p1.workdir.access,
"profile2": p2.workdir.access,
"changed": p1.workdir.access != p2.workdir.access,
},
"network": {
"block": {
"profile1": p1.network.block,
"profile2": p2.network.block,
"changed": p1.network.block != p2.network.block,
},
"network_profile": {
"profile1": p1.network.resolved_network_profile(),
"profile2": p2.network.resolved_network_profile(),
"changed": p1.network.resolved_network_profile() != p2.network.resolved_network_profile(),
},
"allow_domain": diff_vec(&p1.network.allow_domain, &p2.network.allow_domain),
"credentials": diff_vec(p1.network.resolved_credentials(), p2.network.resolved_credentials()),
"open_port": {
"profile1": p1.network.open_port,
"profile2": p2.network.open_port,
"changed": p1.network.open_port != p2.network.open_port,
},
"listen_port": {
"profile1": p1.network.listen_port,
"profile2": p2.network.listen_port,
"changed": p1.network.listen_port != p2.network.listen_port,
},
"upstream_proxy": {
"profile1": p1.network.upstream_proxy,
"profile2": p2.network.upstream_proxy,
"changed": p1.network.upstream_proxy != p2.network.upstream_proxy,
},
"upstream_bypass": diff_vec(
&p1.network.upstream_bypass,
&p2.network.upstream_bypass,
),
"custom_credentials": diff_custom_credentials_json(
&p1.network.custom_credentials,
&p2.network.custom_credentials,
),
},
"env_credentials": {
"profile1": p1.env_credentials.mappings,
"profile2": p2.env_credentials.mappings,
"changed": p1.env_credentials.mappings != p2.env_credentials.mappings,
},
"hooks": diff_hooks_json(&p1.hooks.hooks, &p2.hooks.hooks),
"rollback": {
"exclude_patterns": diff_vec(&p1.rollback.exclude_patterns, &p2.rollback.exclude_patterns),
"exclude_globs": diff_vec(&p1.rollback.exclude_globs, &p2.rollback.exclude_globs),
},
"open_urls": {
"allow_origins": diff_vec(
&ou1.map(|u| u.allow_origins.clone()).unwrap_or_default(),
&ou2.map(|u| u.allow_origins.clone()).unwrap_or_default(),
),
"allow_localhost": {
"profile1": ou1.is_some_and(|u| u.allow_localhost),
"profile2": ou2.is_some_and(|u| u.allow_localhost),
"changed": ou1.is_some_and(|u| u.allow_localhost) != ou2.is_some_and(|u| u.allow_localhost),
},
},
"allow_launch_services": {
"profile1": p1.allow_launch_services,
"profile2": p2.allow_launch_services,
"changed": p1.allow_launch_services != p2.allow_launch_services,
},
"allow_gpu": {
"profile1": p1.allow_gpu,
"profile2": p2.allow_gpu,
"changed": p1.allow_gpu != p2.allow_gpu,
},
})
}
fn diff_fs_json(
fs1: &profile::FilesystemConfig,
fs2: &profile::FilesystemConfig,
) -> serde_json::Value {
let diff_vec = |v1: &[String], v2: &[String]| -> serde_json::Value {
let s1: BTreeSet<&str> = v1.iter().map(|s| s.as_str()).collect();
let s2: BTreeSet<&str> = v2.iter().map(|s| s.as_str()).collect();
let added: Vec<&str> = s2.difference(&s1).copied().collect();
let removed: Vec<&str> = s1.difference(&s2).copied().collect();
serde_json::json!({ "added": added, "removed": removed })
};
serde_json::json!({
"allow": diff_vec(&fs1.allow, &fs2.allow),
"read": diff_vec(&fs1.read, &fs2.read),
"write": diff_vec(&fs1.write, &fs2.write),
"allow_file": diff_vec(&fs1.allow_file, &fs2.allow_file),
"read_file": diff_vec(&fs1.read_file, &fs2.read_file),
"write_file": diff_vec(&fs1.write_file, &fs2.write_file),
"suppress_save_prompt": diff_vec(
&fs1.suppress_save_prompt,
&fs2.suppress_save_prompt
),
})
}
fn diff_hooks_json(
h1: &std::collections::HashMap<String, profile::HookConfig>,
h2: &std::collections::HashMap<String, profile::HookConfig>,
) -> serde_json::Value {
let added: Vec<&String> = h2.keys().filter(|k| !h1.contains_key(*k)).collect();
let removed: Vec<&String> = h1.keys().filter(|k| !h2.contains_key(*k)).collect();
let changed: Vec<&String> = h1
.keys()
.filter(|k| {
h2.get(*k).is_some_and(|v2| {
let v1 = &h1[*k];
v1.event != v2.event || v1.matcher != v2.matcher || v1.script != v2.script
})
})
.collect();
let mut changed_details = serde_json::Map::new();
for k in &changed {
let old = &h1[*k];
let new = &h2[*k];
let mut detail = serde_json::Map::new();
if old.event != new.event {
detail.insert(
"event".into(),
serde_json::json!({"profile1": old.event, "profile2": new.event}),
);
}
if old.matcher != new.matcher {
detail.insert(
"matcher".into(),
serde_json::json!({"profile1": old.matcher, "profile2": new.matcher}),
);
}
if old.script != new.script {
detail.insert(
"script".into(),
serde_json::json!({"profile1": old.script, "profile2": new.script}),
);
}
changed_details.insert((*k).clone(), serde_json::Value::Object(detail));
}
serde_json::json!({
"added": added,
"removed": removed,
"changed": changed_details,
})
}
fn diff_custom_credentials_json(
cc1: &std::collections::HashMap<String, profile::CustomCredentialDef>,
cc2: &std::collections::HashMap<String, profile::CustomCredentialDef>,
) -> serde_json::Value {
let added: Vec<&String> = cc2.keys().filter(|k| !cc1.contains_key(*k)).collect();
let removed: Vec<&String> = cc1.keys().filter(|k| !cc2.contains_key(*k)).collect();
let changed: Vec<&String> = cc1
.keys()
.filter(|k| cc2.get(*k).is_some_and(|v2| cc1[*k] != *v2))
.collect();
let mut changed_details = serde_json::Map::new();
for k in &changed {
let old = &cc1[*k];
let new = &cc2[*k];
let mut detail = serde_json::Map::new();
if old.upstream != new.upstream {
detail.insert(
"upstream".into(),
serde_json::json!({"profile1": old.upstream, "profile2": new.upstream}),
);
}
if old.credential_key != new.credential_key {
detail.insert(
"credential_key".into(),
serde_json::json!({"profile1": old.credential_key, "profile2": new.credential_key}),
);
}
if old.inject_mode != new.inject_mode {
detail.insert(
"inject_mode".into(),
serde_json::json!({"profile1": format!("{:?}", old.inject_mode), "profile2": format!("{:?}", new.inject_mode)}),
);
}
if old.inject_header != new.inject_header {
detail.insert(
"inject_header".into(),
serde_json::json!({"profile1": old.inject_header, "profile2": new.inject_header}),
);
}
if old.credential_format != new.credential_format {
detail.insert(
"credential_format".into(),
serde_json::json!({"profile1": old.credential_format, "profile2": new.credential_format}),
);
}
if old.path_pattern != new.path_pattern {
detail.insert(
"path_pattern".into(),
serde_json::json!({"profile1": old.path_pattern, "profile2": new.path_pattern}),
);
}
if old.path_replacement != new.path_replacement {
detail.insert(
"path_replacement".into(),
serde_json::json!({"profile1": old.path_replacement, "profile2": new.path_replacement}),
);
}
if old.query_param_name != new.query_param_name {
detail.insert(
"query_param_name".into(),
serde_json::json!({"profile1": old.query_param_name, "profile2": new.query_param_name}),
);
}
if old.env_var != new.env_var {
detail.insert(
"env_var".into(),
serde_json::json!({"profile1": old.env_var, "profile2": new.env_var}),
);
}
changed_details.insert((*k).clone(), serde_json::Value::Object(detail));
}
serde_json::json!({
"added": added,
"removed": removed,
"changed": changed_details,
})
}
fn classify_profile_error(e: &NonoError) -> &'static str {
match e {
NonoError::ProfileParse(msg)
if msg.starts_with("expected")
|| msg.contains("line ")
|| msg.contains("column ")
|| msg.contains("EOF") =>
{
"JSON syntax error"
}
NonoError::ProfileParse(_) => "Profile error",
NonoError::ProfileRead { .. } => "File read error",
NonoError::ProfileInheritance(_) => "Inheritance error",
NonoError::ProfileNotFound(_) => "Profile not found",
_ => "Error",
}
}
fn resolve_validate_target(input: &std::path::Path) -> std::path::PathBuf {
if input.exists() {
return input.to_path_buf();
}
let Some(name) = input.to_str() else {
return input.to_path_buf();
};
if name.contains('/') || name.ends_with(".json") {
return input.to_path_buf();
}
if let Ok(p) = profile::get_user_profile_path(name)
&& p.exists()
{
return p;
}
if let Some((p, _)) = profile::find_pack_store_profile(name) {
return p;
}
input.to_path_buf()
}
fn resolve_validate_draft_target(input: &std::path::Path) -> Result<std::path::PathBuf> {
let name = input.to_str().ok_or_else(|| {
NonoError::ProfileParse(format!("invalid draft profile name '{}'", input.display()))
})?;
if !profile::is_valid_profile_name(name) {
return Err(NonoError::ProfileParse(format!(
"invalid draft profile name '{}'",
name
)));
}
profile::get_user_profile_draft_path(name)
}
pub(crate) fn cmd_validate(args: ProfileValidateArgs) -> Result<()> {
let pol = policy::load_embedded_policy()?;
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let target_path = if args.draft {
resolve_validate_draft_target(&args.file)?
} else {
resolve_validate_target(&args.file)
};
let guard = crate::deprecation_warnings::WarningCounterGuard::begin();
let profile = match profile::load_profile_from_path(&target_path) {
Ok(p) => Some(p),
Err(e) => {
let label = classify_profile_error(&e);
errors.push(format!("{}: {}", label, e));
None
}
};
let deprecation_count = guard.finish();
if let Some(ref profile) = profile {
for group_name in &profile.groups.include {
if !pol.groups.contains_key(group_name) {
errors.push(format!("Group '{}' not found in policy.json", group_name));
}
}
for excl in &profile.groups.exclude {
if let Some(group) = pol.groups.get(excl) {
if group.required {
errors.push(format!("Cannot exclude required group '{}'", excl));
}
} else {
warnings.push(format!(
"Excluded group '{}' not found in policy.json",
excl
));
}
}
let check_paths = |paths: &[String], label: &str, w: &mut Vec<String>| {
for p in paths {
if p.trim().is_empty() {
w.push(format!("Empty path in {}", label));
}
}
};
check_paths(&profile.filesystem.allow, "filesystem.allow", &mut warnings);
check_paths(&profile.filesystem.read, "filesystem.read", &mut warnings);
check_paths(&profile.filesystem.write, "filesystem.write", &mut warnings);
check_paths(
&profile.filesystem.suppress_save_prompt,
"filesystem.suppress_save_prompt",
&mut warnings,
);
}
if args.json {
let val = serde_json::json!({
"file": target_path.display().to_string(),
"valid": errors.is_empty(),
"errors": errors,
"warnings": warnings,
"deprecated_keys": deprecation_count,
});
println!("{}", to_json(&val)?);
emit_deprecation_summary(deprecation_count);
if !errors.is_empty() {
return Err(NonoError::ProfileParse("validation failed".into()));
}
if args.strict && deprecation_count > 0 {
std::process::exit(2);
}
return Ok(());
}
let t = theme::current();
println!(
"{}: validating {}",
prefix(),
theme::fg(&target_path.display().to_string(), t.text)
);
println!();
if profile.is_some() {
println!(" {} JSON syntax valid", theme::fg("[ok]", t.green));
}
if let Some(ref profile) = profile {
let valid_groups = profile
.groups
.include
.iter()
.filter(|g| pol.groups.contains_key(g.as_str()))
.count();
let total_groups = profile.groups.include.len();
if valid_groups == total_groups && total_groups > 0 {
println!(
" {} All {} group references valid",
theme::fg("[ok]", t.green),
total_groups
);
}
}
for w in &warnings {
println!(
" {} {}",
theme::fg("[warn]", t.yellow),
theme::fg(w, t.yellow)
);
}
for e in &errors {
println!(" {} {}", theme::fg("[err]", t.red), theme::fg(e, t.red));
}
println!();
if errors.is_empty() {
let suffix = if warnings.is_empty() {
String::new()
} else {
format!(
" ({} warning{})",
warnings.len(),
if warnings.len() == 1 { "" } else { "s" }
)
};
println!(
" Result: {}{}",
theme::fg("valid", t.green).bold(),
theme::fg(&suffix, t.yellow)
);
emit_deprecation_summary(deprecation_count);
if args.strict && deprecation_count > 0 {
std::process::exit(2);
}
Ok(())
} else {
println!(
" Result: {} ({} error{})",
theme::fg("invalid", t.red).bold(),
errors.len(),
if errors.len() == 1 { "" } else { "s" }
);
emit_deprecation_summary(deprecation_count);
Err(NonoError::ProfileParse("validation failed".into()))
}
}
pub(crate) fn cmd_promote(args: ProfilePromoteArgs) -> Result<()> {
if args.diff && args.yes {
return Err(NonoError::ProfileParse(
"`--diff` cannot be combined with `--yes`".to_string(),
));
}
if !profile::is_valid_profile_name(&args.name) {
return Err(NonoError::ProfileParse(format!(
"invalid draft profile name '{}'",
args.name
)));
}
let draft_path = profile::get_user_profile_draft_path(&args.name)?;
let base_path = profile::get_user_profile_draft_base_path(&args.name)?;
let target_path = profile::get_user_profile_path(&args.name)?;
let draft_bytes = read_regular_file(&draft_path, "profile draft")?;
let raw_profile = profile::parse_profile_bytes(&draft_bytes)?;
if raw_profile.meta.name != args.name {
return Err(NonoError::ProfileParse(format!(
"draft meta.name '{}' does not match draft name '{}'",
raw_profile.meta.name, args.name
)));
}
let resolved_profile = profile::resolve_and_finalize_profile(raw_profile)?;
validate_promote_profile(&resolved_profile)?;
let target_exists = regular_file_exists(&target_path, "target profile")?;
let current_bytes = if target_exists {
Some(read_regular_file(&target_path, "target profile")?)
} else {
None
};
if current_bytes.is_none()
&& let Some(source) = reserved_profile_source(&args.name)?
{
return Err(NonoError::ProfileParse(format!(
"refusing to promote '{}' because it would shadow a {source} profile. \
Draft a derived profile such as '{}-local' with \"extends\": \"{}\" instead.",
args.name, args.name, args.name
)));
}
if let Some(current) = current_bytes.as_deref() {
verify_base_hash(&base_path, current)?;
}
print_promote_diff(&args.name, current_bytes.as_deref(), &draft_bytes);
if args.diff {
return Ok(());
}
if !args.yes && !crate::profile_save_runtime::confirm("Promote this draft? [y/N] ", false)? {
eprintln!("{} promotion skipped", prefix());
return Ok(());
}
atomic_write_file(&target_path, &draft_bytes)?;
let _ = fs::remove_file(&draft_path);
let _ = fs::remove_file(&base_path);
eprintln!(
"{} Promoted draft to {}",
prefix(),
target_path.display().to_string().bold()
);
Ok(())
}
fn validate_promote_profile(profile: &Profile) -> Result<()> {
let pol = policy::load_embedded_policy()?;
let mut errors = Vec::new();
for group_name in &profile.groups.include {
if !pol.groups.contains_key(group_name) {
errors.push(format!("group '{}' not found in policy.json", group_name));
}
}
for excluded in &profile.groups.exclude {
match pol.groups.get(excluded) {
Some(group) if group.required => {
errors.push(format!("cannot exclude required group '{}'", excluded));
}
Some(_) | None => {}
}
}
for (label, paths) in [
("filesystem.allow", &profile.filesystem.allow),
("filesystem.read", &profile.filesystem.read),
("filesystem.write", &profile.filesystem.write),
("filesystem.allow_file", &profile.filesystem.allow_file),
("filesystem.read_file", &profile.filesystem.read_file),
("filesystem.write_file", &profile.filesystem.write_file),
] {
if paths.iter().any(|path| path.trim().is_empty()) {
errors.push(format!("empty path in {label}"));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(NonoError::ProfileParse(format!(
"draft profile failed validation: {}",
errors.join("; ")
)))
}
}
fn reserved_profile_source(name: &str) -> Result<Option<&'static str>> {
let pol = policy::load_embedded_policy()?;
if pol.profiles.contains_key(name) {
return Ok(Some("built-in"));
}
if profile::find_pack_store_profile(name).is_some() {
return Ok(Some("installed pack"));
}
Ok(None)
}
fn verify_base_hash(base_path: &Path, current_bytes: &[u8]) -> Result<()> {
let base_bytes = read_regular_file(base_path, "profile draft base hash")?;
let provided = std::str::from_utf8(&base_bytes)
.map_err(|e| NonoError::ProfileParse(format!("base hash is not UTF-8: {e}")))?
.trim();
if provided.len() != 64 || !provided.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(NonoError::ProfileParse(format!(
"invalid base hash in {}",
base_path.display()
)));
}
let current = sha256_hex(current_bytes);
if !provided.eq_ignore_ascii_case(¤t) {
return Err(NonoError::ProfileParse(
"draft base hash does not match current profile. The profile changed after the draft was written; regenerate or review the draft before promoting."
.to_string(),
));
}
Ok(())
}
fn read_regular_file(path: &Path, label: &str) -> Result<Vec<u8>> {
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
options.custom_flags(nix::libc::O_NOFOLLOW);
}
let mut file = options.open(path).map_err(|e| NonoError::ProfileRead {
path: path.to_path_buf(),
source: e,
})?;
let metadata = file.metadata().map_err(|e| NonoError::ProfileRead {
path: path.to_path_buf(),
source: e,
})?;
if !metadata.file_type().is_file() {
return Err(NonoError::ProfileParse(format!(
"{label} is not a regular file: {}",
path.display()
)));
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|e| NonoError::ProfileRead {
path: path.to_path_buf(),
source: e,
})?;
Ok(bytes)
}
fn regular_file_exists(path: &Path, label: &str) -> Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(NonoError::ProfileParse(format!(
"{label} must not be a symlink: {}",
path.display()
)));
}
if !metadata.file_type().is_file() {
return Err(NonoError::ProfileParse(format!(
"{label} is not a regular file: {}",
path.display()
)));
}
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(NonoError::ProfileRead {
path: path.to_path_buf(),
source: error,
}),
}
}
fn atomic_write_file(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
NonoError::ProfileParse(format!("invalid profile path {}", path.display()))
})?;
fs::create_dir_all(parent).map_err(|e| NonoError::ProfileRead {
path: parent.to_path_buf(),
source: e,
})?;
let file_name = path.file_name().ok_or_else(|| {
NonoError::ProfileParse(format!("invalid profile path {}", path.display()))
})?;
let mut tmp_name = std::ffi::OsString::from(".");
tmp_name.push(file_name);
tmp_name.push(format!(".tmp.{}", std::process::id()));
let tmp_path = parent.join(tmp_name);
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.map_err(|e| NonoError::ProfileRead {
path: tmp_path.clone(),
source: e,
})?;
if let Err(error) = file.write_all(contents) {
let _ = fs::remove_file(&tmp_path);
return Err(NonoError::ProfileRead {
path: tmp_path,
source: error,
});
}
if let Err(error) = file.sync_all() {
let _ = fs::remove_file(&tmp_path);
return Err(NonoError::ProfileRead {
path: tmp_path,
source: error,
});
}
drop(file);
if let Err(error) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(NonoError::ProfileRead {
path: path.to_path_buf(),
source: error,
});
}
Ok(())
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn print_promote_diff(name: &str, current: Option<&[u8]>, draft: &[u8]) {
use similar::{ChangeTag, TextDiff};
let old_text = current
.map(String::from_utf8_lossy)
.unwrap_or_else(|| "".into());
let new_text = String::from_utf8_lossy(draft);
let t = theme::current();
println!(
"{}: promote draft '{}'",
prefix(),
theme::fg(name, t.text).bold()
);
println!();
println!(" {}", theme::fg("Diff:", t.subtext).bold());
println!("--- profiles/{name}.json");
println!("+++ profile-drafts/{name}.json");
let diff = TextDiff::from_lines(&old_text, &new_text);
let mut changed = false;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Delete => {
changed = true;
print!("{}", format!("-{change}").red());
}
ChangeTag::Insert => {
changed = true;
print!("{}", format!("+{change}").green());
}
ChangeTag::Equal => print!(" {change}"),
}
}
if !changed {
println!(" {}", theme::fg("(no differences)", t.subtext));
}
println!();
}
fn emit_deprecation_summary(count: usize) {
if count == 0 {
return;
}
let _ = writeln!(
std::io::stderr(),
"found {count} deprecated keys; run 'nono profile guide' for migration mapping"
);
}
#[allow(deprecated)] fn resolve_to_manifest(
prof: &Profile,
workdir: &std::path::Path,
) -> Result<nono::manifest::CapabilityManifest> {
use nono::manifest;
let expand = |tmpl: &str| -> Result<String> {
let expanded = profile::expand_vars(tmpl, workdir)?;
Ok(expanded.to_string_lossy().into_owned())
};
let mut grants = Vec::new();
let mut deny = Vec::new();
let fs_sources: &[(&[String], manifest::AccessMode, bool)] = &[
(
&prof.filesystem.allow,
manifest::AccessMode::Readwrite,
false,
),
(&prof.filesystem.read, manifest::AccessMode::Read, false),
(&prof.filesystem.write, manifest::AccessMode::Write, false),
(
&prof.filesystem.allow_file,
manifest::AccessMode::Readwrite,
true,
),
(&prof.filesystem.read_file, manifest::AccessMode::Read, true),
(
&prof.filesystem.write_file,
manifest::AccessMode::Write,
true,
),
];
for (paths, access, is_file) in fs_sources {
for p in *paths {
grants.push(make_fs_grant(&expand(p)?, *access, *is_file)?);
}
}
for p in &prof.filesystem.deny {
let expanded = expand(p)?;
deny.push(manifest::FsDeny {
path: expanded
.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid deny path: {e}")))?,
});
}
let loaded_policy = policy::load_embedded_policy()?;
let mut scratch_caps = nono::CapabilitySet::new();
let resolved_groups =
policy::resolve_groups(&loaded_policy, &prof.groups.include, &mut scratch_caps)?;
for cap in scratch_caps.fs_capabilities() {
let access = match cap.access {
nono::AccessMode::Read => manifest::AccessMode::Read,
nono::AccessMode::Write => manifest::AccessMode::Write,
nono::AccessMode::ReadWrite => manifest::AccessMode::Readwrite,
};
let path_str = cap.resolved.to_string_lossy().into_owned();
grants.push(make_fs_grant(&path_str, access, cap.is_file)?);
}
let bypass_protection_expanded: Vec<std::path::PathBuf> = prof
.filesystem
.bypass_protection
.iter()
.filter_map(|tmpl| profile::expand_vars(tmpl, workdir).ok())
.map(|p| {
if p.exists() {
p.canonicalize().unwrap_or(p)
} else {
p
}
})
.collect();
for deny_path in resolved_groups.deny_paths.iter().filter(|dp| {
!bypass_protection_expanded
.iter()
.any(|ovr| dp.starts_with(ovr))
}) {
let path_str = deny_path.to_string_lossy().into_owned();
deny.push(manifest::FsDeny {
path: path_str
.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid deny path: {e}")))?,
});
}
let group_blocked_commands: Vec<String> = scratch_caps.blocked_commands().to_vec();
let workdir_str = workdir.to_string_lossy().into_owned();
match prof.workdir.access {
WorkdirAccess::ReadWrite => {
grants.push(make_fs_grant(
&workdir_str,
manifest::AccessMode::Readwrite,
false,
)?);
}
WorkdirAccess::Read => {
grants.push(make_fs_grant(
&workdir_str,
manifest::AccessMode::Read,
false,
)?);
}
WorkdirAccess::Write => {
grants.push(make_fs_grant(
&workdir_str,
manifest::AccessMode::Write,
false,
)?);
}
WorkdirAccess::None => {} }
grants.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
grants.dedup_by(|a, b| {
if a.path.as_str() == b.path.as_str() && a.type_ == b.type_ {
b.access = wider_access(a.access, b.access);
true
} else {
false
}
});
deny.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
deny.dedup_by(|a, b| a.path.as_str() == b.path.as_str());
let filesystem = if grants.is_empty() && deny.is_empty() {
None
} else {
Some(manifest::Filesystem { grants, deny })
};
let network_mode = if prof.network.block {
manifest::NetworkMode::Blocked
} else if prof.network.resolved_network_profile().is_some()
|| !prof.network.allow_domain.is_empty()
|| !prof.network.resolved_credentials().is_empty()
|| !prof.network.custom_credentials.is_empty()
{
manifest::NetworkMode::Proxy
} else {
manifest::NetworkMode::Unrestricted
};
let network = Some(manifest::Network {
mode: network_mode,
allow_domains: prof.network.allow_domain.clone(),
endpoints: Vec::new(),
dns: true,
ports: if prof.network.listen_port.is_empty() && prof.network.open_port.is_empty() {
None
} else {
Some(manifest::PortConfig {
connect: Vec::new(),
bind: prof
.network
.listen_port
.iter()
.filter_map(|p| std::num::NonZeroU64::new(u64::from(*p)))
.collect(),
localhost: prof
.network
.open_port
.iter()
.filter_map(|p| std::num::NonZeroU64::new(u64::from(*p)))
.collect(),
})
},
});
let signal_mode = match prof.security.signal_mode {
Some(profile::ProfileSignalMode::Isolated) | None => manifest::SignalMode::Isolated,
Some(profile::ProfileSignalMode::AllowSameSandbox) => {
manifest::SignalMode::AllowSameSandbox
}
Some(profile::ProfileSignalMode::AllowAll) => manifest::SignalMode::AllowAll,
};
let process_info_mode = match prof.security.process_info_mode {
Some(profile::ProfileProcessInfoMode::Isolated) | None => {
manifest::ProcessInfoMode::Isolated
}
Some(profile::ProfileProcessInfoMode::AllowSameSandbox) => {
manifest::ProcessInfoMode::AllowSameSandbox
}
Some(profile::ProfileProcessInfoMode::AllowAll) => manifest::ProcessInfoMode::AllowAll,
};
let ipc_mode = match prof.security.ipc_mode {
Some(profile::ProfileIpcMode::SharedMemoryOnly) | None => {
manifest::IpcMode::SharedMemoryOnly
}
Some(profile::ProfileIpcMode::Full) => manifest::IpcMode::Full,
};
let process = Some(manifest::Process {
allowed_commands: prof.commands.allow.clone(),
blocked_commands: {
let mut cmds = group_blocked_commands;
cmds.extend(prof.commands.deny.clone());
cmds.sort();
cmds.dedup();
cmds
},
exec_strategy: if !prof.rollback.exclude_patterns.is_empty()
|| !prof.rollback.exclude_globs.is_empty()
{
manifest::ExecStrategy::Supervised
} else {
manifest::ExecStrategy::Monitor
},
signal_mode,
process_info_mode,
ipc_mode,
});
let rollback =
if prof.rollback.exclude_patterns.is_empty() && prof.rollback.exclude_globs.is_empty() {
None
} else {
Some(manifest::Rollback {
enabled: false,
exclude_patterns: prof.rollback.exclude_patterns.clone(),
exclude_globs: prof.rollback.exclude_globs.clone(),
})
};
let mut credentials = Vec::new();
for (name, cred) in &prof.network.custom_credentials {
let inject_mode = match cred.inject_mode {
profile::InjectMode::Header => manifest::InjectMode::Header,
profile::InjectMode::UrlPath => manifest::InjectMode::UrlPath,
profile::InjectMode::QueryParam => manifest::InjectMode::QueryParam,
profile::InjectMode::BasicAuth => manifest::InjectMode::BasicAuth,
};
let endpoint_rules: Vec<manifest::EndpointRule> = cred
.endpoint_rules
.iter()
.map(|r| {
let method = r.method.parse().map_err(|e| {
NonoError::ConfigParse(format!(
"invalid endpoint rule method '{}': {e}",
r.method
))
})?;
let path = r.path.parse().map_err(|e| {
NonoError::ConfigParse(format!("invalid endpoint rule path '{}': {e}", r.path))
})?;
Ok(manifest::EndpointRule { method, path })
})
.collect::<Result<Vec<_>>>()?;
credentials.push(manifest::Credential {
name: name
.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid credential name: {e}")))?,
upstream: cred
.upstream
.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid credential upstream: {e}")))?,
source: match cred.credential_key.as_ref() {
Some(key) => key.parse().map_err(|e| {
NonoError::ConfigParse(format!("invalid credential source: {e}"))
})?,
None => continue,
},
inject: Some(manifest::CredentialInject {
mode: inject_mode,
header: cred.inject_header.clone(),
format: nono_proxy::config::resolved_credential_format(
&cred.inject_header,
cred.credential_format.as_deref(),
),
path_pattern: cred.path_pattern.clone(),
path_replacement: cred.path_replacement.clone(),
query_param_name: cred.query_param_name.clone(),
}),
env_var: cred
.env_var
.as_ref()
.map(|v| {
v.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid env_var: {e}")))
})
.transpose()?,
endpoint_rules,
});
}
let version = "0.1.0"
.parse()
.map_err(|e| NonoError::ConfigParse(format!("version parse error: {e}")))?;
Ok(manifest::CapabilityManifest {
version,
schema: Some("https://nono.dev/schemas/capability-manifest.schema.json".to_string()),
filesystem,
network,
process,
rollback,
credentials,
})
}
fn wider_access(
a: nono::manifest::AccessMode,
b: nono::manifest::AccessMode,
) -> nono::manifest::AccessMode {
use nono::manifest::AccessMode::{Read, Readwrite, Write};
match (a, b) {
(Readwrite, _) | (_, Readwrite) => Readwrite,
(Read, Write) | (Write, Read) => Readwrite,
(Read, Read) => Read,
(Write, Write) => Write,
}
}
fn make_fs_grant(
path: &str,
access: nono::manifest::AccessMode,
is_file: bool,
) -> Result<nono::manifest::FsGrant> {
Ok(nono::manifest::FsGrant {
path: path
.parse()
.map_err(|e| NonoError::ConfigParse(format!("invalid grant path: {e}")))?,
access,
type_: if is_file {
nono::manifest::FsEntryType::File
} else {
nono::manifest::FsEntryType::Directory
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::Profile;
use std::path::PathBuf;
#[test]
fn embedded_guide_contains_no_nono_policy_references() {
let text = crate::config::embedded::embedded_profile_guide();
assert!(
!text.contains("nono policy "),
"profile-authoring-guide.md references deprecated 'nono policy ' commands — update to 'nono profile '",
);
}
#[test]
fn test_minimal_skeleton_is_valid_profile() {
let args = ProfileInitArgs {
name: "test-profile".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
let json = serde_json::to_string(&skeleton).expect("serialize");
let profile: Profile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(profile.meta.name, "test-profile");
}
#[test]
fn test_full_skeleton_is_valid_profile() {
let args = ProfileInitArgs {
name: "full-test".to_string(),
extends: Some("default".to_string()),
groups: vec![],
description: Some("A full test profile".to_string()),
full: true,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
let json = serde_json::to_string(&skeleton).expect("serialize");
let profile: Profile = serde_json::from_str(&json).expect("deserialize");
assert_eq!(profile.meta.name, "full-test");
assert_eq!(profile.extends, Some(vec!["default".to_string()]));
assert_eq!(
profile.meta.description,
Some("A full test profile".to_string())
);
}
#[test]
fn test_skeleton_with_groups() {
let args = ProfileInitArgs {
name: "grouped".to_string(),
extends: None,
groups: vec!["deny_credentials".to_string()],
description: None,
full: false,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
let groups = skeleton["groups"]["include"].as_array().expect("array");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0], "deny_credentials");
}
#[test]
fn test_skeleton_omits_schema_url() {
let args = ProfileInitArgs {
name: "schema-test".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
assert!(skeleton.get("$schema").is_none());
}
#[test]
fn test_invalid_profile_name() {
let result = cmd_init(ProfileInitArgs {
name: "-bad-name-".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: Some(PathBuf::from("/tmp/nono-test-bad.json")),
force: false,
});
assert!(result.is_err());
let err = result.expect_err("error");
assert!(err.to_string().contains("Invalid profile name"));
}
#[test]
fn test_invalid_group_name() {
let result = cmd_init(ProfileInitArgs {
name: "test-profile".to_string(),
extends: None,
groups: vec!["nonexistent_group_xyz".to_string()],
description: None,
full: false,
output: Some(PathBuf::from("/tmp/nono-test-badgroup.json")),
force: false,
});
assert!(result.is_err());
let err = result.expect_err("error");
assert!(err.to_string().contains("Unknown security group"));
}
#[test]
fn test_invalid_extends_target() {
let result = cmd_init(ProfileInitArgs {
name: "test-profile".to_string(),
extends: Some("nonexistent-base-profile-xyz".to_string()),
groups: vec![],
description: None,
full: false,
output: Some(PathBuf::from("/tmp/nono-test-badextends.json")),
force: false,
});
assert!(result.is_err());
let err = result.expect_err("error");
assert!(err.to_string().contains("not found"));
}
#[test]
fn test_init_blocked_when_shadowing_builtin() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let result = cmd_init(ProfileInitArgs {
name: "opencode".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
});
assert!(result.is_err());
let err = result.expect_err("error");
assert!(
matches!(err, nono::NonoError::Cancelled(_)),
"expected Cancelled (shadow block), got: {err}"
);
}
#[test]
fn test_init_blocked_with_custom_output_when_shadowing_builtin() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let out = dir.path().join("opencode-draft.json");
let result = cmd_init(ProfileInitArgs {
name: "opencode".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: Some(out.clone()),
force: false,
});
assert!(result.is_err());
let err = result.expect_err("error");
assert!(
matches!(err, nono::NonoError::Cancelled(_)),
"expected Cancelled (shadow block), got: {err}"
);
}
#[test]
fn test_init_allowed_when_pack_has_same_short_name() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let pack_dir = xdg
.join("nono")
.join("packages")
.join("test-ns")
.join("test-pack");
std::fs::create_dir_all(pack_dir.join("profiles")).expect("mkdir pack");
let manifest = r#"{
"schema_version": 1,
"name": "test-pack",
"artifacts": [
{"type": "profile", "path": "profiles/my-agent.json", "install_as": "my-agent"}
]
}"#;
std::fs::write(pack_dir.join("package.json"), manifest).expect("write manifest");
std::fs::write(
pack_dir.join("profiles").join("my-agent.json"),
"{\"meta\":{\"name\":\"my-agent\",\"version\":\"1.0.0\"}}\n",
)
.expect("write pack profile");
let profiles_dir = xdg.join("nono").join("profiles");
std::fs::create_dir_all(&profiles_dir).expect("mkdir profiles");
let result = cmd_init(ProfileInitArgs {
name: "my-agent".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
});
assert!(result.is_ok(), "expected ok, got: {:?}", result.err());
}
#[test]
fn profile_validate_target_prefers_name_for_default_user_profile_path() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let args = ProfileInitArgs {
name: "copilot".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
};
let output_path = profile::get_user_profile_path("copilot").expect("profile path");
assert_eq!(profile_validate_target(&args, &output_path), "copilot");
}
#[test]
fn profile_validate_target_uses_path_for_custom_output() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let output_path = dir.path().join("custom-copilot.json");
let args = ProfileInitArgs {
name: "copilot".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: Some(output_path.clone()),
force: false,
};
assert_eq!(
profile_validate_target(&args, &output_path),
output_path.display().to_string()
);
}
#[test]
fn test_force_overwrite() {
use std::io::Write;
let tmp = std::env::temp_dir().join("nono-test-force-overwrite.json");
let mut f = fs::File::create(&tmp).expect("create");
f.write_all(b"{}").expect("write");
drop(f);
let result = cmd_init(ProfileInitArgs {
name: "test-profile".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: Some(tmp.clone()),
force: false,
});
assert!(result.is_err());
let result = cmd_init(ProfileInitArgs {
name: "test-profile".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: Some(tmp.clone()),
force: true,
});
assert!(result.is_ok());
let content = fs::read_to_string(&tmp).expect("read");
let profile: Profile = serde_json::from_str(&content).expect("parse");
assert_eq!(profile.meta.name, "test-profile");
let _ = fs::remove_file(&tmp);
}
#[test]
fn test_full_vs_minimal_differences() {
let minimal_args = ProfileInitArgs {
name: "minimal".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
};
let full_args = ProfileInitArgs {
name: "full".to_string(),
extends: None,
groups: vec![],
description: None,
full: true,
output: None,
force: false,
};
let minimal = build_skeleton(&minimal_args);
let full = build_skeleton(&full_args);
let minimal_obj = minimal.as_object().expect("object");
let full_obj = full.as_object().expect("object");
assert!(full_obj.len() > minimal_obj.len());
assert!(!full_obj.contains_key("policy"));
assert!(full_obj.contains_key("commands"));
assert!(full_obj.contains_key("network"));
assert!(full_obj.contains_key("env_credentials"));
assert!(full_obj.contains_key("hooks"));
assert!(full_obj.contains_key("rollback"));
assert!(!full_obj.contains_key("open_urls"));
assert!(!full_obj.contains_key("allow_launch_services"));
assert!(!full_obj.contains_key("allow_gpu"));
assert!(!minimal_obj.contains_key("commands"));
assert!(!minimal_obj.contains_key("network"));
assert!(!minimal_obj.contains_key("hooks"));
let full_fs = full_obj["filesystem"].as_object().expect("fs object");
assert!(full_fs.contains_key("write"));
assert!(full_fs.contains_key("allow_file"));
assert!(full_fs.contains_key("read_file"));
assert!(full_fs.contains_key("write_file"));
assert!(full_fs.contains_key("deny"));
assert!(full_fs.contains_key("bypass_protection"));
assert!(full_fs.contains_key("suppress_save_prompt"));
let min_fs = minimal_obj["filesystem"].as_object().expect("fs object");
assert!(!min_fs.contains_key("write"));
assert!(!min_fs.contains_key("allow_file"));
assert!(!min_fs.contains_key("deny"));
assert!(!min_fs.contains_key("bypass_protection"));
assert!(!min_fs.contains_key("suppress_save_prompt"));
let full_groups = full_obj["groups"].as_object().expect("groups object");
assert!(full_groups.contains_key("include"));
assert!(full_groups.contains_key("exclude"));
let min_groups = minimal_obj["groups"].as_object().expect("groups object");
assert!(min_groups.contains_key("include"));
assert!(!min_groups.contains_key("exclude"));
let full_cmds = full_obj["commands"].as_object().expect("commands object");
assert!(full_cmds.contains_key("allow"));
assert!(full_cmds.contains_key("deny"));
let full_net = full_obj["network"].as_object().expect("network object");
assert!(full_net.contains_key("allow_domain"));
assert!(full_net.contains_key("credentials"));
assert!(full_net.contains_key("open_port"));
assert!(full_net.contains_key("listen_port"));
assert!(full_net.contains_key("custom_credentials"));
}
#[test]
fn test_full_skeleton_emits_zero_deprecation_warnings() {
use crate::deprecation_warnings::WarningCounterGuard;
let args = ProfileInitArgs {
name: "skeleton-zero-warn".to_string(),
extends: Some("default".to_string()),
groups: vec![],
description: None,
full: true,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
let json = serde_json::to_string(&skeleton).expect("serialize");
let guard = WarningCounterGuard::begin();
let _profile: Profile = serde_json::from_str(&json).expect("deserialize");
let count = guard.finish();
assert_eq!(
count, 0,
"build_skeleton --full produced {count} deprecation warning(s); skeleton must use canonical schema only"
);
}
#[test]
fn test_minimal_skeleton_emits_zero_deprecation_warnings() {
use crate::deprecation_warnings::WarningCounterGuard;
let args = ProfileInitArgs {
name: "skeleton-min-zero-warn".to_string(),
extends: None,
groups: vec![],
description: None,
full: false,
output: None,
force: false,
};
let skeleton = build_skeleton(&args);
let json = serde_json::to_string(&skeleton).expect("serialize");
let guard = WarningCounterGuard::begin();
let _profile: Profile = serde_json::from_str(&json).expect("deserialize");
let count = guard.finish();
assert_eq!(
count, 0,
"build_skeleton minimal produced {count} deprecation warning(s)"
);
}
#[test]
fn test_groups_lists_all() {
let pol = policy::load_embedded_policy().expect("should load policy");
assert!(
pol.groups.len() > 10,
"expected many groups, got {}",
pol.groups.len()
);
assert!(
pol.groups.contains_key("deny_credentials"),
"expected deny_credentials group"
);
}
#[test]
fn test_groups_specific_known() {
let pol = policy::load_embedded_policy().expect("should load policy");
let group = pol
.groups
.get("deny_credentials")
.expect("deny_credentials should exist");
assert!(!group.description.is_empty());
assert!(group.required);
if let Some(ref deny) = group.deny {
let all_paths = deny.access.join(" ");
assert!(all_paths.contains(".ssh"), "expected .ssh in deny paths");
assert!(all_paths.contains(".aws"), "expected .aws in deny paths");
} else {
panic!("deny_credentials should have deny rules");
}
}
#[test]
fn test_groups_unknown_errors() {
let pol = policy::load_embedded_policy().expect("should load policy");
let result = cmd_groups_detail(&pol, "nonexistent_group_xyz", false);
assert!(result.is_err());
}
#[test]
fn test_profiles_includes_builtins() {
let profiles = profile::list_profiles();
assert!(
profiles.contains(&"default".to_string()),
"expected 'default' in profiles"
);
assert!(
profiles.contains(&"opencode".to_string()),
"expected 'codex' in profiles"
);
}
#[test]
fn test_show_resolves_inheritance() {
let profile = profile::load_profile("opencode").expect("opencode profile should load");
assert!(
!profile.groups.include.is_empty(),
"opencode should have groups"
);
let has_deny = profile.groups.include.iter().any(|g| g.contains("deny"));
assert!(has_deny, "opencode should inherit deny groups");
}
#[test]
fn test_diff_shows_differences() {
let p1 = profile::load_profile("default").expect("default should load");
let p2 = profile::load_profile("opencode").expect("opencode should load");
let g1: BTreeSet<&str> = p1.groups.include.iter().map(|s| s.as_str()).collect();
let g2: BTreeSet<&str> = p2.groups.include.iter().map(|s| s.as_str()).collect();
let added: BTreeSet<&&str> = g2.difference(&g1).collect();
assert!(
!added.is_empty(),
"codex should have additional groups over default"
);
}
#[test]
fn test_validate_valid_profile() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test-profile.json");
std::fs::write(
&path,
r#"{
"meta": { "name": "test", "description": "test profile" },
"groups": { "include": ["deny_credentials"] },
"workdir": { "access": "readwrite" }
}"#,
)
.expect("write");
let args = ProfileValidateArgs {
file: path,
draft: false,
json: false,
strict: false,
help: None,
};
let result = cmd_validate(args);
assert!(result.is_ok(), "valid profile should pass validation");
}
#[test]
fn test_validate_invalid_group() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad-profile.json");
std::fs::write(
&path,
r#"{
"meta": { "name": "test" },
"groups": { "include": ["nonexistent_group_xyz"] }
}"#,
)
.expect("write");
let args = ProfileValidateArgs {
file: path,
draft: false,
json: false,
strict: false,
help: None,
};
let result = cmd_validate(args);
assert!(result.is_err(), "invalid group should fail validation");
}
#[test]
fn test_validate_exclude_required() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad-exclude.json");
std::fs::write(
&path,
r#"{
"meta": { "name": "test" },
"groups": { "include": [], "exclude": ["deny_credentials"] }
}"#,
)
.expect("write");
let args = ProfileValidateArgs {
file: path,
draft: false,
json: false,
strict: false,
help: None,
};
let result = cmd_validate(args);
assert!(
result.is_err(),
"excluding required group should fail validation"
);
}
#[test]
fn promote_creates_new_profile_from_draft_and_removes_draft() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let draft_dir = profile::user_profile_draft_dir().expect("draft dir");
std::fs::create_dir_all(&draft_dir).expect("create drafts");
let draft_path = profile::get_user_profile_draft_path("agent-local").expect("draft path");
std::fs::write(
&draft_path,
r#"{
"meta": { "name": "agent-local" },
"filesystem": { "read": ["/tmp"] }
}
"#,
)
.expect("write draft");
let result = cmd_promote(ProfilePromoteArgs {
name: "agent-local".to_string(),
diff: false,
yes: true,
help: None,
});
assert!(result.is_ok(), "promote should succeed: {result:?}");
let target = profile::get_user_profile_path("agent-local").expect("target path");
assert!(target.exists(), "target profile should exist");
assert!(
!draft_path.exists(),
"draft should be removed after promote"
);
}
#[test]
fn promote_existing_profile_requires_matching_base_hash() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let profiles_dir = profile::user_profile_dir().expect("profile dir");
let draft_dir = profile::user_profile_draft_dir().expect("draft dir");
std::fs::create_dir_all(&profiles_dir).expect("create profiles");
std::fs::create_dir_all(&draft_dir).expect("create drafts");
let target = profile::get_user_profile_path("agent-local").expect("target path");
let old = b"{\n \"meta\": { \"name\": \"agent-local\" },\n \"filesystem\": { \"read\": [\"/tmp\"] }\n}\n";
std::fs::write(&target, old).expect("write target");
let draft = profile::get_user_profile_draft_path("agent-local").expect("draft path");
std::fs::write(
&draft,
"{\n \"meta\": { \"name\": \"agent-local\" },\n \"filesystem\": { \"read\": [\"/var/tmp\"] }\n}\n",
)
.expect("write draft");
let missing_base = cmd_promote(ProfilePromoteArgs {
name: "agent-local".to_string(),
diff: false,
yes: true,
help: None,
});
assert!(
missing_base.is_err(),
"existing profile promote must require .base"
);
let base = profile::get_user_profile_draft_base_path("agent-local").expect("base path");
std::fs::write(&base, sha256_hex(old)).expect("write base");
let result = cmd_promote(ProfilePromoteArgs {
name: "agent-local".to_string(),
diff: false,
yes: true,
help: None,
});
assert!(result.is_ok(), "promote should succeed: {result:?}");
let promoted = std::fs::read_to_string(&target).expect("read promoted");
assert!(promoted.contains("/var/tmp"));
}
#[test]
fn promote_refuses_to_shadow_builtin_profile() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("config");
std::fs::create_dir_all(&xdg).expect("create xdg");
let xdg_str = xdg.to_str().expect("utf8 xdg");
let _env = crate::test_env::EnvVarGuard::set_all(&[("XDG_CONFIG_HOME", xdg_str)]);
let draft_dir = profile::user_profile_draft_dir().expect("draft dir");
std::fs::create_dir_all(&draft_dir).expect("create drafts");
let draft_path = profile::get_user_profile_draft_path("default").expect("draft path");
std::fs::write(
&draft_path,
r#"{
"meta": { "name": "default" },
"filesystem": { "read": ["/tmp"] }
}
"#,
)
.expect("write draft");
let result = cmd_promote(ProfilePromoteArgs {
name: "default".to_string(),
diff: false,
yes: true,
help: None,
});
assert!(
result.is_err(),
"promote should refuse to shadow built-in profiles"
);
}
}