use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{
contracts::{find_contracts_manifest, load_contracts, AuthorityTrustLevel},
rbac::RbacProfile,
};
#[derive(Debug, serde::Deserialize)]
struct CellosPolicyPack {
#[serde(rename = "allowedSecretRefs")]
allowed_secret_refs: Vec<String>,
#[serde(rename = "requiredSecretRefs")]
required_secret_refs: Option<Vec<String>>,
#[serde(rename = "allowedTargets")]
allowed_targets: Option<Vec<String>>,
#[serde(rename = "accessProfile")]
access_profile: Option<RbacProfile>,
#[serde(rename = "trustLevel")]
trust_level: Option<AuthorityTrustLevel>,
}
#[derive(Debug)]
struct PolicyExpectation {
allowed_secret_refs: BTreeSet<String>,
required_secret_refs: Option<BTreeSet<String>>,
allowed_targets: Option<BTreeSet<String>>,
access_profile: Option<RbacProfile>,
trust_level: Option<AuthorityTrustLevel>,
}
#[derive(Debug)]
struct ContractPosture {
name: String,
allowed_secret_refs: BTreeSet<String>,
required_secret_refs: BTreeSet<String>,
allowed_targets: BTreeSet<String>,
access_profile: RbacProfile,
trust_level: AuthorityTrustLevel,
}
impl ContractPosture {
fn mismatch_fields(&self, policy: &PolicyExpectation) -> Vec<&'static str> {
let mut mismatches = Vec::new();
if self.allowed_secret_refs != policy.allowed_secret_refs {
mismatches.push("allowedSecretRefs");
}
if policy
.required_secret_refs
.as_ref()
.is_some_and(|required| &self.required_secret_refs != required)
{
mismatches.push("requiredSecretRefs");
}
if policy
.allowed_targets
.as_ref()
.is_some_and(|targets| &self.allowed_targets != targets)
{
mismatches.push("allowedTargets");
}
if policy
.access_profile
.is_some_and(|access| self.access_profile != access)
{
mismatches.push("accessProfile");
}
if policy
.trust_level
.is_some_and(|trust| self.trust_level != trust)
{
mismatches.push("trustLevel");
}
mismatches
}
fn mismatch_descriptions(&self, policy: &PolicyExpectation) -> Vec<String> {
let mut mismatches = Vec::new();
if self.allowed_secret_refs != policy.allowed_secret_refs {
mismatches.push(format!(
"allowedSecretRefs contract=[{}] policy=[{}]",
join_sorted(&self.allowed_secret_refs),
join_sorted(&policy.allowed_secret_refs)
));
}
if let Some(required) = policy.required_secret_refs.as_ref() {
if &self.required_secret_refs != required {
mismatches.push(format!(
"requiredSecretRefs contract=[{}] policy=[{}]",
join_sorted(&self.required_secret_refs),
join_sorted(required)
));
}
}
if let Some(targets) = policy.allowed_targets.as_ref() {
if &self.allowed_targets != targets {
mismatches.push(format!(
"allowedTargets contract=[{}] policy=[{}]",
join_sorted(&self.allowed_targets),
join_sorted(targets)
));
}
}
if let Some(access_profile) = policy.access_profile {
if self.access_profile != access_profile {
mismatches.push(format!(
"accessProfile contract={} policy={}",
self.access_profile.as_str(),
access_profile.as_str()
));
}
}
if let Some(trust_level) = policy.trust_level {
if self.trust_level != trust_level {
mismatches.push(format!(
"trustLevel contract={} policy={}",
self.trust_level.as_str(),
trust_level.as_str()
));
}
}
mismatches
}
}
fn join_sorted(values: &BTreeSet<String>) -> String {
if values.is_empty() {
"(none)".to_string()
} else {
values.iter().cloned().collect::<Vec<_>>().join(", ")
}
}
pub(crate) fn cmd_validate(cellos_policy_path: &Path, json_out: bool) -> Result<()> {
let cwd = std::env::current_dir().context("could not read current directory")?;
let manifest_path = find_contracts_manifest(&cwd).ok_or_else(|| {
anyhow::anyhow!(
"no .tsafe.yml or .tsafe.json found in '{}' or any parent directory",
cwd.display()
)
})?;
let contracts = load_contracts(&manifest_path).context("failed to load authority contracts")?;
let mut contract_secrets: BTreeSet<String> = BTreeSet::new();
let mut contract_required_secrets: BTreeSet<String> = BTreeSet::new();
let mut contract_targets: BTreeSet<String> = BTreeSet::new();
let contract_postures: Vec<ContractPosture> = contracts
.values()
.map(|contract| {
for s in &contract.allowed_secrets {
contract_secrets.insert(s.clone());
}
for s in &contract.required_secrets {
contract_required_secrets.insert(s.clone());
}
for target in &contract.allowed_targets {
contract_targets.insert(target.clone());
}
let resolved = contract.resolved_exec_policy();
ContractPosture {
name: contract.name.clone(),
allowed_secret_refs: contract.allowed_secrets.iter().cloned().collect(),
required_secret_refs: contract.required_secrets.iter().cloned().collect(),
allowed_targets: contract.allowed_targets.iter().cloned().collect(),
access_profile: resolved.access_profile,
trust_level: resolved.trust_level,
}
})
.collect();
let policy_json = std::fs::read_to_string(cellos_policy_path).with_context(|| {
format!(
"could not read CellOS policy pack '{}'",
cellos_policy_path.display()
)
})?;
let policy: CellosPolicyPack = serde_json::from_str(&policy_json).with_context(|| {
format!(
"invalid CellOS policy JSON in '{}'",
cellos_policy_path.display()
)
})?;
let policy = PolicyExpectation {
allowed_secret_refs: policy.allowed_secret_refs.into_iter().collect(),
required_secret_refs: policy
.required_secret_refs
.map(|names| names.into_iter().collect()),
allowed_targets: policy
.allowed_targets
.map(|names| names.into_iter().collect()),
access_profile: policy.access_profile,
trust_level: policy.trust_level,
};
let mut matching_contracts = Vec::new();
let mut mismatch_rows: Vec<(String, Vec<String>)> = Vec::new();
let mut conflicts: BTreeSet<&'static str> = BTreeSet::new();
for contract in &contract_postures {
let mismatch_fields = contract.mismatch_fields(&policy);
if mismatch_fields.is_empty() {
matching_contracts.push(contract.name.clone());
continue;
}
for field in mismatch_fields {
conflicts.insert(field);
}
mismatch_rows.push((
contract.name.clone(),
contract.mismatch_descriptions(&policy),
));
}
let passed = !matching_contracts.is_empty();
let conflict_fields: Vec<&'static str> = conflicts.into_iter().collect();
if json_out {
let mismatches_json: Vec<serde_json::Value> = mismatch_rows
.iter()
.map(|(name, descs)| {
serde_json::json!({
"contract": name,
"mismatches": descs,
})
})
.collect();
let out = serde_json::json!({
"manifest": manifest_path.display().to_string(),
"policy_file": cellos_policy_path.display().to_string(),
"contract_count": contracts.len(),
"passed": passed,
"matching_contracts": matching_contracts,
"conflict_fields": conflict_fields,
"contract_mismatches": mismatches_json,
});
println!("{}", serde_json::to_string_pretty(&out)?);
if !passed {
anyhow::bail!(
"{} conflict(s) found — align .tsafe.yml with CellOS {}",
conflict_fields.len(),
conflict_fields.join(", ")
);
}
return Ok(());
}
println!(
"{} Validating '{}' against '{}'",
"i".blue(),
manifest_path.display(),
cellos_policy_path.display()
);
println!(
" Contracts: {} | Contract secrets: {} | Policy allowedSecretRefs: {}",
contracts.len(),
contract_secrets.len(),
policy.allowed_secret_refs.len()
);
println!(
" Contract required secrets: {} | Contract allowed_targets: {}",
contract_required_secrets.len(),
contract_targets.len()
);
println!();
println!(" Contract posture:");
for contract in &contract_postures {
println!(
" - {} access={} trust={} required={} targets={}",
contract.name.cyan(),
contract.access_profile.as_str().cyan(),
contract.trust_level.as_str().cyan(),
contract.required_secret_refs.len(),
contract.allowed_targets.len()
);
}
println!();
if passed {
println!(
"{} No conflicts — matching contract(s): {}",
"✓".green(),
matching_contracts.join(", ").cyan()
);
return Ok(());
}
println!(
"{} No single contract in '{}' matches the CellOS policy pack exactly.",
"!".yellow().bold(),
manifest_path.display()
);
for (name, mismatches) in mismatch_rows {
println!(" {} {}:", "–".yellow(), name.cyan());
for mismatch in mismatches {
println!(" {}", mismatch.yellow());
}
}
println!();
anyhow::bail!(
"{} conflict(s) found — align .tsafe.yml with CellOS {}",
conflict_fields.len(),
conflict_fields.join(", ")
);
}