use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::vault::validate_secret_key;
pub const KEYS_SCHEMA: &str = "tsafe.tooling.keys.v1";
pub const POLICY_SCHEMA: &str = "tsafe.tooling_policy.v1";
pub type Result<T> = std::result::Result<T, ToolingInventoryError>;
#[derive(Debug, Error)]
pub enum ToolingInventoryError {
#[error("{0}")]
InvalidInput(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InventoryEntry {
pub section: String,
pub key: String,
pub purpose: String,
pub consumer: String,
pub rotation: String,
pub line: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolingInitReport {
pub created: bool,
pub root: PathBuf,
pub tooling_dir: PathBuf,
pub keys_path: PathBuf,
pub policy_path: PathBuf,
pub readme_path: PathBuf,
pub namespace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryCheckReport {
pub ok: bool,
pub root: PathBuf,
pub keys_path: PathBuf,
pub namespace: Option<String>,
pub entries: Vec<InventoryEntry>,
pub warnings: Vec<String>,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SuggestKey {
pub key: String,
pub purpose: String,
pub consumer: String,
pub rotation: String,
pub section: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestKeysRequest {
pub namespace: String,
pub source: String,
pub reason: String,
pub apply: bool,
pub keys: Vec<SuggestKey>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestKeysReport {
pub suggestion_id: String,
pub root: PathBuf,
pub keys_path: PathBuf,
pub suggestions_path: PathBuf,
pub receipt_path: Option<PathBuf>,
pub namespace: String,
pub added_keys: Vec<String>,
pub existing_keys: Vec<String>,
pub preview_keys: Vec<String>,
pub applied: bool,
pub vault_values_written: bool,
}
#[derive(Debug, Clone)]
struct ParsedInventory {
namespace: Option<String>,
entries: Vec<InventoryEntry>,
warnings: Vec<String>,
errors: Vec<String>,
}
pub fn tooling_dir(root: &Path) -> PathBuf {
root.join(".tsafe").join("tooling")
}
pub fn keys_path(root: &Path) -> PathBuf {
tooling_dir(root).join("keys.ini")
}
pub fn policy_path(root: &Path) -> PathBuf {
tooling_dir(root).join("policy.toml")
}
pub fn suggestions_path(root: &Path) -> PathBuf {
tooling_dir(root).join("suggestions.jsonl")
}
pub fn receipts_dir(root: &Path) -> PathBuf {
tooling_dir(root).join("receipts")
}
pub fn readme_path(root: &Path) -> PathBuf {
tooling_dir(root).join("README.md")
}
pub fn init_tooling(
root: &Path,
namespace: Option<&str>,
force: bool,
) -> Result<ToolingInitReport> {
let root = normalize_root(root);
let namespace = normalize_namespace(namespace.unwrap_or(&default_namespace(&root)))?;
let tooling = tooling_dir(&root);
fs::create_dir_all(&tooling)?;
fs::create_dir_all(receipts_dir(&root))?;
let keys = keys_path(&root);
let policy = policy_path(&root);
let readme = readme_path(&root);
let mut created = false;
if force || !keys.exists() {
fs::write(&keys, render_keys_ini(&root, &namespace))?;
created = true;
}
if force || !policy.exists() {
fs::write(&policy, render_policy(&namespace))?;
created = true;
}
if force || !readme.exists() {
fs::write(&readme, render_readme())?;
created = true;
}
Ok(ToolingInitReport {
created,
root,
tooling_dir: tooling,
keys_path: keys,
policy_path: policy,
readme_path: readme,
namespace,
})
}
pub fn check_inventory(root: &Path) -> Result<InventoryCheckReport> {
let root = normalize_root(root);
let keys = keys_path(&root);
if !keys.exists() {
return Ok(InventoryCheckReport {
ok: false,
root,
keys_path: keys,
namespace: None,
entries: Vec::new(),
warnings: Vec::new(),
errors: vec!["missing .tsafe/tooling/keys.ini".to_string()],
});
}
let text = fs::read_to_string(&keys)?;
let parsed = parse_keys_ini(&text);
let ok = parsed.errors.is_empty();
Ok(InventoryCheckReport {
ok,
root,
keys_path: keys,
namespace: parsed.namespace,
entries: parsed.entries,
warnings: parsed.warnings,
errors: parsed.errors,
})
}
pub fn suggest_keys(root: &Path, request: SuggestKeysRequest) -> Result<SuggestKeysReport> {
if request.keys.is_empty() {
return Err(ToolingInventoryError::InvalidInput(
"at least one suggested key is required".to_string(),
));
}
validate_comment_field("source", &request.source)?;
validate_comment_field("reason", &request.reason)?;
let root = normalize_root(root);
let namespace = normalize_namespace(&request.namespace)?;
if !keys_path(&root).exists() {
init_tooling(&root, Some(&namespace), false)?;
}
let check = check_inventory(&root)?;
if !check.ok {
return Err(ToolingInventoryError::InvalidInput(format!(
"keys.ini is not valid: {}",
check.errors.join("; ")
)));
}
let existing: BTreeSet<String> = check
.entries
.iter()
.map(|entry| entry.key.clone())
.collect();
let suggestion_id = format!("sug_{}", Uuid::new_v4().simple());
let mut added = Vec::new();
let mut existing_keys = Vec::new();
let mut preview = Vec::new();
let mut by_section: BTreeMap<String, Vec<(String, &SuggestKey)>> = BTreeMap::new();
for item in &request.keys {
let full_key = normalize_suggested_key(&namespace, &item.key)?;
if has_plaintext_secret_shape(&item.purpose)
|| has_plaintext_secret_shape(&item.consumer)
|| has_plaintext_secret_shape(&item.rotation)
{
return Err(ToolingInventoryError::InvalidInput(
"suggestions may describe secret slots but must not include secret values"
.to_string(),
));
}
validate_metadata_field("purpose", &item.purpose)?;
validate_metadata_field("consumer", &item.consumer)?;
validate_metadata_field("rotation", &item.rotation)?;
if existing.contains(&full_key) {
existing_keys.push(full_key);
continue;
}
preview.push(full_key.clone());
if request.apply {
added.push(full_key.clone());
let section = item
.section
.as_deref()
.map(sanitize_section)
.unwrap_or_else(|| "suggested".to_string());
by_section
.entry(section)
.or_default()
.push((full_key, item));
}
}
if request.apply && !by_section.is_empty() {
append_to_keys_ini(
&root,
&suggestion_id,
&request.source,
&request.reason,
by_section,
)?;
}
let suggestions = suggestions_path(&root);
append_suggestion_record(
&root,
&suggestion_id,
&request,
&preview,
&added,
&existing_keys,
)?;
let receipt = if request.apply {
Some(write_receipt(
&root,
&suggestion_id,
&request,
&added,
&existing_keys,
)?)
} else {
None
};
Ok(SuggestKeysReport {
suggestion_id,
root: root.clone(),
keys_path: keys_path(&root),
suggestions_path: suggestions,
receipt_path: receipt,
namespace,
added_keys: added,
existing_keys,
preview_keys: preview,
applied: request.apply,
vault_values_written: false,
})
}
fn normalize_root(root: &Path) -> PathBuf {
if root.as_os_str().is_empty() {
PathBuf::from(".")
} else {
root.to_path_buf()
}
}
fn default_namespace(root: &Path) -> String {
let name = root
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("repo");
format!("{}/", sanitize_key_part(name))
}
fn normalize_namespace(raw: &str) -> Result<String> {
let trimmed = raw.trim().trim_start_matches('/').replace('\\', "/");
if trimmed.is_empty() {
return Err(ToolingInventoryError::InvalidInput(
"namespace must not be empty".to_string(),
));
}
let namespace = if trimmed.ends_with('/') {
trimmed
} else {
format!("{trimmed}/")
};
if namespace.contains("//")
|| namespace
.trim_end_matches('/')
.split('/')
.any(|part| part.trim().is_empty())
{
return Err(ToolingInventoryError::InvalidInput(format!(
"namespace '{namespace}' is not valid"
)));
}
validate_secret_key(namespace.trim_end_matches('/')).map_err(|err| {
ToolingInventoryError::InvalidInput(format!("namespace '{namespace}' is not valid: {err}"))
})?;
Ok(namespace)
}
fn normalize_suggested_key(namespace: &str, raw: &str) -> Result<String> {
let key = raw.trim().trim_start_matches('/').replace('\\', "/");
if key.is_empty() {
return Err(ToolingInventoryError::InvalidInput(
"suggested key must not be empty".to_string(),
));
}
let full = if key.starts_with(namespace) {
key
} else if key.contains('/') {
return Err(ToolingInventoryError::InvalidInput(format!(
"suggested key '{key}' is outside namespace '{namespace}'"
)));
} else {
format!("{namespace}{}", sanitize_key_part(&key))
};
if full.contains("//") || full.ends_with('/') {
return Err(ToolingInventoryError::InvalidInput(format!(
"suggested key '{full}' is not valid"
)));
}
validate_secret_key(&full).map_err(|err| {
ToolingInventoryError::InvalidInput(format!("suggested key '{full}' is not valid: {err}"))
})?;
Ok(full)
}
fn sanitize_key_part(raw: &str) -> String {
raw.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'_'
}
})
.collect()
}
fn sanitize_section(raw: &str) -> String {
let section: String = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
ch
} else {
'-'
}
})
.filter(|ch| !ch.is_whitespace())
.collect();
if section.is_empty() {
"suggested".to_string()
} else {
section
}
}
fn validate_metadata_field(label: &str, raw: &str) -> Result<()> {
let value = raw.trim();
if value.is_empty() {
return Err(ToolingInventoryError::InvalidInput(format!(
"{label} must not be empty"
)));
}
if value.contains('|') || value.chars().any(|ch| ch.is_control()) {
return Err(ToolingInventoryError::InvalidInput(format!(
"{label} must not contain control characters or `|`"
)));
}
Ok(())
}
fn validate_comment_field(label: &str, raw: &str) -> Result<()> {
let value = raw.trim();
if value.is_empty() {
return Err(ToolingInventoryError::InvalidInput(format!(
"{label} must not be empty"
)));
}
if value.chars().any(|ch| ch.is_control()) {
return Err(ToolingInventoryError::InvalidInput(format!(
"{label} must not contain control characters"
)));
}
Ok(())
}
fn render_keys_ini(root: &Path, namespace: &str) -> String {
let repo = root.file_name().and_then(|s| s.to_str()).unwrap_or("repo");
format!(
r#"# tsafe secret inventory for {repo}
# Namespace: {namespace}
# Format: key = purpose | consumer | rotation
# Values never belong in this file.
[inventory]
schema = {KEYS_SCHEMA}
namespace = {namespace}
[ci-cd-spn]
# key/name = purpose | consumer | rotation
# {namespace}ci_cd_app_id = SPN app ID | CI service connection | static
[sql-bridge]
# {namespace}sql_password = SQL password | runtime bridge | 365d KV policy
[scim]
# {namespace}scim_pat = SCIM PAT | provisioning app | manual rotation on expiry
[ops-read-only]
# {namespace}readonly_app_id = read-only SPN app ID | discovery scripts | static
"#
)
}
fn render_policy(namespace: &str) -> String {
format!(
r#"schema = "{POLICY_SCHEMA}"
[namespace]
default = "{namespace}"
allow = ["{namespace}"]
[mcp]
suggest_enabled = true
auto_write_keys_ini = true
auto_write_vault_values = false
require_receipt = true
reject_plaintext_values = true
"#
)
}
fn render_readme() -> &'static str {
r#"# tsafe tooling inventory
This folder records secret slots for this repository. It is safe to commit when
it contains only key names, purpose, consumer, and rotation metadata.
- `keys.ini` is the human inventory.
- `policy.toml` controls agent/MCP suggestion behavior.
- `suggestions.jsonl` is an append-only suggestion log.
- `receipts/` contains one JSON receipt per applied suggestion.
Do not put secret values, tokens, private keys, vault files, or dotenv files in
this folder.
"#
}
fn parse_keys_ini(text: &str) -> ParsedInventory {
let mut section: Option<String> = None;
let mut namespace = None;
let mut entries = Vec::new();
let mut warnings = Vec::new();
let mut errors = Vec::new();
let mut seen = BTreeSet::new();
for (idx, raw_line) in text.lines().enumerate() {
let line_no = idx + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
let name = line.trim_start_matches('[').trim_end_matches(']').trim();
if name.is_empty() {
errors.push(format!("line {line_no}: empty section name"));
section = None;
} else {
section = Some(name.to_string());
}
continue;
}
let current_section = section.clone().unwrap_or_else(|| "default".to_string());
let Some((lhs, rhs)) = line.split_once('=') else {
errors.push(format!(
"line {line_no}: expected `key = purpose | consumer | rotation`"
));
continue;
};
let key = lhs.trim();
let value = rhs.trim();
if current_section == "inventory" {
match key {
"schema" if value != KEYS_SCHEMA => warnings.push(format!(
"line {line_no}: schema '{value}' is not the current {KEYS_SCHEMA}"
)),
"namespace" => match normalize_namespace(value) {
Ok(ns) => namespace = Some(ns),
Err(err) => errors.push(format!("line {line_no}: {err}")),
},
_ => {}
}
continue;
}
let parts: Vec<&str> = value.split('|').map(str::trim).collect();
if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
errors.push(format!(
"line {line_no}: expected `key = purpose | consumer | rotation`"
));
continue;
}
if key.is_empty() || !key.contains('/') {
errors.push(format!(
"line {line_no}: key '{key}' must include a namespace prefix"
));
continue;
}
if let Err(err) = validate_secret_key(key) {
errors.push(format!("line {line_no}: key '{key}' is not valid: {err}"));
continue;
}
if has_plaintext_secret_shape(value) {
errors.push(format!(
"line {line_no}: row appears to contain a plaintext secret value"
));
continue;
}
if !seen.insert(key.to_string()) {
errors.push(format!("line {line_no}: duplicate key '{key}'"));
}
if let Some(ns) = namespace.as_deref() {
if !key.starts_with(ns) {
errors.push(format!(
"line {line_no}: key '{key}' is outside namespace '{ns}'"
));
}
}
entries.push(InventoryEntry {
section: current_section,
key: key.to_string(),
purpose: parts[0].to_string(),
consumer: parts[1].to_string(),
rotation: parts[2].to_string(),
line: line_no,
});
}
if namespace.is_none() {
warnings.push("missing [inventory] namespace".to_string());
}
ParsedInventory {
namespace,
entries,
warnings,
errors,
}
}
fn has_plaintext_secret_shape(value: &str) -> bool {
let v = value.trim();
if v.contains("-----BEGIN ") || v.contains(" PRIVATE KEY-----") {
return true;
}
let lower = v.to_ascii_lowercase();
if lower.contains("github_pat_") || lower.contains("ghp_") || lower.contains("sk-") {
return true;
}
v.split(|ch: char| !ch.is_ascii_alphanumeric())
.any(|part| part.starts_with("AKIA") && part.len() >= 16)
}
fn append_to_keys_ini(
root: &Path,
suggestion_id: &str,
source: &str,
reason: &str,
by_section: BTreeMap<String, Vec<(String, &SuggestKey)>>,
) -> Result<()> {
let path = keys_path(root);
let mut file = fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)?;
writeln!(
file,
"\n# tsafe suggestion {suggestion_id}: source={source}; reason={reason}"
)?;
for (section, rows) in by_section {
writeln!(file, "\n[{section}]")?;
for (full_key, item) in rows {
writeln!(
file,
"{full_key} = {} | {} | {}",
item.purpose.trim(),
item.consumer.trim(),
item.rotation.trim()
)?;
}
}
Ok(())
}
fn append_suggestion_record(
root: &Path,
suggestion_id: &str,
request: &SuggestKeysRequest,
preview: &[String],
added: &[String],
existing: &[String],
) -> Result<()> {
let path = suggestions_path(root);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let record = serde_json::json!({
"schema": "tsafe.tooling.suggestion.v1",
"suggestion_id": suggestion_id,
"created_at": Utc::now().to_rfc3339(),
"source": request.source,
"reason": request.reason,
"apply": request.apply,
"preview_keys": preview,
"added_keys": added,
"existing_keys": existing,
"vault_values_written": false,
});
writeln!(file, "{}", serde_json::to_string(&record)?)?;
Ok(())
}
fn write_receipt(
root: &Path,
suggestion_id: &str,
request: &SuggestKeysRequest,
added: &[String],
existing: &[String],
) -> Result<PathBuf> {
let dir = receipts_dir(root);
fs::create_dir_all(&dir)?;
let path = dir.join(format!("{suggestion_id}.json"));
let receipt = serde_json::json!({
"schema": "tsafe.tooling.receipt.v1",
"suggestion_id": suggestion_id,
"created_at": Utc::now().to_rfc3339(),
"source": request.source,
"reason": request.reason,
"added_keys": added,
"existing_keys": existing,
"keys_ini": keys_path(root),
"vault_values_written": false,
});
fs::write(&path, serde_json::to_string_pretty(&receipt)?)?;
Ok(path)
}