use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use chrono::Utc;
use tsafe_core::attest_contract::{
is_supported_source_uri, AttestAllowedEnv, AttestContract, AttestContractPolicy,
AttestDeniedEnv, ATTEST_CONTRACT_SCHEMA, REDACTION_BLAKE3,
};
use crate::model::{FindingKind, ScanReport};
use crate::scan;
const SAFE_BASELINE_ENV: &[&str] = &[
"PATH", "HOME", "TMPDIR", "TEMP", "USER", "SHELL", "NODE_ENV", "CI",
];
pub struct PlanOptions {
pub repo: PathBuf,
pub scan_path: Option<PathBuf>,
pub inline_sources: Vec<String>,
pub sources_file: Option<PathBuf>,
pub command: Vec<String>,
}
pub fn create_plan(options: PlanOptions) -> Result<AttestContract> {
if options.command.is_empty() {
bail!("plan requires a command after --");
}
let repo = fs::canonicalize(&options.repo)
.with_context(|| format!("repo not found: {}", options.repo.display()))?;
let scan_path = options
.scan_path
.clone()
.unwrap_or_else(|| PathBuf::from("tsafe-scan.json"));
let report = load_or_create_scan(&repo, &scan_path, options.scan_path.is_some())?;
let source_map = load_source_map(&options.inline_sources, options.sources_file.as_deref())?;
let mut observed: BTreeSet<String> = report
.observed_env_reads
.iter()
.map(|read| read.name.clone())
.collect();
for name in source_map.keys() {
observed.insert(name.clone());
}
let mut allowed_env = Vec::new();
for name in observed {
let explicitly_mapped = source_map.contains_key(&name);
if scan::is_high_risk_env_name(&name) && !explicitly_mapped {
continue;
}
let source = source_map
.get(&name)
.cloned()
.unwrap_or_else(|| format!("literal://demo/{name}"));
allowed_env.push(AttestAllowedEnv {
required: is_required_by_default(&name),
reason: observed_reason(&report, &name),
name,
source,
});
}
let allowed_names: BTreeSet<String> = allowed_env.iter().map(|env| env.name.clone()).collect();
let mut denied_reasons: BTreeMap<String, String> = BTreeMap::new();
for (name, _) in std::env::vars() {
if scan::is_sensitive_env_name(&name) && !allowed_names.contains(&name) {
denied_reasons.insert(
name,
"Ambient sensitive variable not required by command".to_string(),
);
}
}
for finding in &report.findings {
let Some(name) = &finding.name else {
continue;
};
if allowed_names.contains(name) {
continue;
}
if matches!(
finding.kind,
FindingKind::EnvFile | FindingKind::HardcodedSecret | FindingKind::CiSecretReference
) && scan::is_sensitive_env_name(name)
{
denied_reasons
.entry(name.clone())
.or_insert_with(|| "Secret-like finding not required by command".to_string());
}
}
let denied_env = denied_reasons
.into_iter()
.map(|(name, reason)| AttestDeniedEnv { name, reason })
.collect();
let contract = AttestContract {
schema: ATTEST_CONTRACT_SCHEMA.to_string(),
repo_path: repo.display().to_string(),
repo_commit: report.repo_commit.clone(),
created_at: Utc::now(),
created_by: "tsafe attest plan".to_string(),
command: options.command,
policy: AttestContractPolicy {
default_env: "deny".to_string(),
allow_safe_baseline: true,
redaction: REDACTION_BLAKE3.to_string(),
},
allowed_env,
denied_env,
safe_baseline_env: SAFE_BASELINE_ENV
.iter()
.map(|name| (*name).to_string())
.collect(),
source_scan: Some(scan_path.display().to_string()),
};
contract
.ensure_valid()
.map_err(|errors| anyhow::anyhow!("plan produced invalid contract: {errors}"))?;
Ok(contract)
}
pub fn write_contract(contract: &AttestContract, output: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(contract)?;
ensure_parent_dir(output)?;
fs::write(output, json).with_context(|| format!("write contract: {}", output.display()))
}
pub fn print_summary(contract: &AttestContract) {
println!("tsafe attest plan — contract drafted");
println!("Command:");
println!(" {}", contract.command.join(" "));
println!("Allowed by draft:");
for env in &contract.allowed_env {
println!(" {}", env.name);
}
println!("Denied by draft:");
for env in &contract.denied_env {
println!(" {}", env.name);
}
println!("Policy:");
println!(" default_env: {}", contract.policy.default_env);
println!("Note: MVP only enforces environment authority, not file-backed credentials.");
}
fn load_or_create_scan(repo: &Path, scan_path: &Path, explicit_scan: bool) -> Result<ScanReport> {
if scan_path.exists() {
let json = fs::read_to_string(scan_path)
.with_context(|| format!("read scan report: {}", scan_path.display()))?;
return serde_json::from_str(&json).context("parse scan report");
}
if explicit_scan {
bail!("scan report does not exist: {}", scan_path.display());
}
let report = scan::scan_repo(repo)?;
scan::write_scan(&report, scan_path)?;
Ok(report)
}
fn load_source_map(inline: &[String], file: Option<&Path>) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
if let Some(file) = file {
let json = fs::read_to_string(file)
.with_context(|| format!("read sources: {}", file.display()))?;
map.extend(serde_json::from_str::<BTreeMap<String, String>>(&json)?);
}
for mapping in inline {
let Some((name, source)) = mapping.split_once('=') else {
bail!("invalid --source mapping, expected NAME=source://value: {mapping}");
};
map.insert(name.to_string(), source.to_string());
}
for (name, source) in &map {
if name.trim().is_empty() {
bail!("invalid --source mapping: env name must not be empty");
}
if !is_supported_source_uri(source) {
bail!(
"invalid --source mapping for {name}: unsupported source {source}; MVP supports literal://demo/* and env://*"
);
}
}
Ok(map)
}
fn observed_reason(report: &ScanReport, name: &str) -> String {
report
.observed_env_reads
.iter()
.find(|read| read.name == name)
.map(|read| format!("Observed read in {}", read.file))
.unwrap_or_else(|| "Explicit source mapping supplied".to_string())
}
fn is_required_by_default(name: &str) -> bool {
matches!(name, "DATABASE_URL") || name.ends_with("_URL")
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent)
.with_context(|| format!("create output directory: {}", parent.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ObservedEnvRead, ScanReport, ScanSummary, SCAN_SCHEMA};
#[test]
fn creates_default_deny_contract() {
let tmp = tempfile::tempdir().unwrap();
let scan_path = tmp.path().join("scan.json");
let report = ScanReport {
schema: SCAN_SCHEMA.to_string(),
repo_path: tmp.path().display().to_string(),
repo_commit: None,
scanned_at: chrono::Utc::now(),
scanner_version: "test".to_string(),
findings: vec![],
observed_env_reads: vec![ObservedEnvRead {
name: "DATABASE_URL".to_string(),
file: "src/config.js".to_string(),
line: 1,
language: "javascript".to_string(),
confidence: 0.9,
}],
ci_secret_references: vec![],
summary: ScanSummary::default(),
};
fs::write(&scan_path, serde_json::to_string(&report).unwrap()).unwrap();
let contract = create_plan(PlanOptions {
repo: tmp.path().to_path_buf(),
scan_path: Some(scan_path),
inline_sources: vec![],
sources_file: None,
command: vec!["npm".to_string(), "test".to_string()],
})
.unwrap();
assert_eq!(contract.policy.default_env, "deny");
assert_eq!(contract.command, ["npm", "test"]);
assert!(contract
.allowed_env
.iter()
.any(|env| env.name == "DATABASE_URL"));
assert!(contract.safe_baseline_env.contains(&"PATH".to_string()));
assert_eq!(contract.policy.redaction, REDACTION_BLAKE3);
assert_eq!(contract.schema, ATTEST_CONTRACT_SCHEMA);
}
}