1use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use uuid::Uuid;
16
17use crate::vault::validate_secret_key;
18
19pub const KEYS_SCHEMA: &str = "tsafe.tooling.keys.v1";
20pub const POLICY_SCHEMA: &str = "tsafe.tooling_policy.v1";
21
22pub type Result<T> = std::result::Result<T, ToolingInventoryError>;
23
24#[derive(Debug, Error)]
25pub enum ToolingInventoryError {
26 #[error("{0}")]
27 InvalidInput(String),
28 #[error("io error: {0}")]
29 Io(#[from] std::io::Error),
30 #[error("json error: {0}")]
31 Json(#[from] serde_json::Error),
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct InventoryEntry {
36 pub section: String,
37 pub key: String,
38 pub purpose: String,
39 pub consumer: String,
40 pub rotation: String,
41 pub line: usize,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ToolingInitReport {
46 pub created: bool,
47 pub root: PathBuf,
48 pub tooling_dir: PathBuf,
49 pub keys_path: PathBuf,
50 pub policy_path: PathBuf,
51 pub readme_path: PathBuf,
52 pub namespace: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct InventoryCheckReport {
57 pub ok: bool,
58 pub root: PathBuf,
59 pub keys_path: PathBuf,
60 pub namespace: Option<String>,
61 pub entries: Vec<InventoryEntry>,
62 pub warnings: Vec<String>,
63 pub errors: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct SuggestKey {
68 pub key: String,
69 pub purpose: String,
70 pub consumer: String,
71 pub rotation: String,
72 pub section: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SuggestKeysRequest {
77 pub namespace: String,
78 pub source: String,
79 pub reason: String,
80 pub apply: bool,
81 pub keys: Vec<SuggestKey>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SuggestKeysReport {
86 pub suggestion_id: String,
87 pub root: PathBuf,
88 pub keys_path: PathBuf,
89 pub suggestions_path: PathBuf,
90 pub receipt_path: Option<PathBuf>,
91 pub namespace: String,
92 pub added_keys: Vec<String>,
93 pub existing_keys: Vec<String>,
94 pub preview_keys: Vec<String>,
95 pub applied: bool,
96 pub vault_values_written: bool,
97}
98
99#[derive(Debug, Clone)]
100struct ParsedInventory {
101 namespace: Option<String>,
102 entries: Vec<InventoryEntry>,
103 warnings: Vec<String>,
104 errors: Vec<String>,
105}
106
107pub fn tooling_dir(root: &Path) -> PathBuf {
108 root.join(".tsafe").join("tooling")
109}
110
111pub fn keys_path(root: &Path) -> PathBuf {
112 tooling_dir(root).join("keys.ini")
113}
114
115pub fn policy_path(root: &Path) -> PathBuf {
116 tooling_dir(root).join("policy.toml")
117}
118
119pub fn suggestions_path(root: &Path) -> PathBuf {
120 tooling_dir(root).join("suggestions.jsonl")
121}
122
123pub fn receipts_dir(root: &Path) -> PathBuf {
124 tooling_dir(root).join("receipts")
125}
126
127pub fn readme_path(root: &Path) -> PathBuf {
128 tooling_dir(root).join("README.md")
129}
130
131pub fn init_tooling(
132 root: &Path,
133 namespace: Option<&str>,
134 force: bool,
135) -> Result<ToolingInitReport> {
136 let root = normalize_root(root);
137 let namespace = normalize_namespace(namespace.unwrap_or(&default_namespace(&root)))?;
138 let tooling = tooling_dir(&root);
139 fs::create_dir_all(&tooling)?;
140 fs::create_dir_all(receipts_dir(&root))?;
141
142 let keys = keys_path(&root);
143 let policy = policy_path(&root);
144 let readme = readme_path(&root);
145 let mut created = false;
146
147 if force || !keys.exists() {
148 fs::write(&keys, render_keys_ini(&root, &namespace))?;
149 created = true;
150 }
151 if force || !policy.exists() {
152 fs::write(&policy, render_policy(&namespace))?;
153 created = true;
154 }
155 if force || !readme.exists() {
156 fs::write(&readme, render_readme())?;
157 created = true;
158 }
159
160 Ok(ToolingInitReport {
161 created,
162 root,
163 tooling_dir: tooling,
164 keys_path: keys,
165 policy_path: policy,
166 readme_path: readme,
167 namespace,
168 })
169}
170
171pub fn check_inventory(root: &Path) -> Result<InventoryCheckReport> {
172 let root = normalize_root(root);
173 let keys = keys_path(&root);
174 if !keys.exists() {
175 return Ok(InventoryCheckReport {
176 ok: false,
177 root,
178 keys_path: keys,
179 namespace: None,
180 entries: Vec::new(),
181 warnings: Vec::new(),
182 errors: vec!["missing .tsafe/tooling/keys.ini".to_string()],
183 });
184 }
185
186 let text = fs::read_to_string(&keys)?;
187 let parsed = parse_keys_ini(&text);
188 let ok = parsed.errors.is_empty();
189
190 Ok(InventoryCheckReport {
191 ok,
192 root,
193 keys_path: keys,
194 namespace: parsed.namespace,
195 entries: parsed.entries,
196 warnings: parsed.warnings,
197 errors: parsed.errors,
198 })
199}
200
201pub fn suggest_keys(root: &Path, request: SuggestKeysRequest) -> Result<SuggestKeysReport> {
202 if request.keys.is_empty() {
203 return Err(ToolingInventoryError::InvalidInput(
204 "at least one suggested key is required".to_string(),
205 ));
206 }
207 validate_comment_field("source", &request.source)?;
208 validate_comment_field("reason", &request.reason)?;
209
210 let root = normalize_root(root);
211 let namespace = normalize_namespace(&request.namespace)?;
212 if !keys_path(&root).exists() {
213 init_tooling(&root, Some(&namespace), false)?;
214 }
215
216 let check = check_inventory(&root)?;
217 if !check.ok {
218 return Err(ToolingInventoryError::InvalidInput(format!(
219 "keys.ini is not valid: {}",
220 check.errors.join("; ")
221 )));
222 }
223
224 let existing: BTreeSet<String> = check
225 .entries
226 .iter()
227 .map(|entry| entry.key.clone())
228 .collect();
229 let suggestion_id = format!("sug_{}", Uuid::new_v4().simple());
230 let mut added = Vec::new();
231 let mut existing_keys = Vec::new();
232 let mut preview = Vec::new();
233 let mut by_section: BTreeMap<String, Vec<(String, &SuggestKey)>> = BTreeMap::new();
234
235 for item in &request.keys {
236 let full_key = normalize_suggested_key(&namespace, &item.key)?;
237 if has_plaintext_secret_shape(&item.purpose)
238 || has_plaintext_secret_shape(&item.consumer)
239 || has_plaintext_secret_shape(&item.rotation)
240 {
241 return Err(ToolingInventoryError::InvalidInput(
242 "suggestions may describe secret slots but must not include secret values"
243 .to_string(),
244 ));
245 }
246 validate_metadata_field("purpose", &item.purpose)?;
247 validate_metadata_field("consumer", &item.consumer)?;
248 validate_metadata_field("rotation", &item.rotation)?;
249 if existing.contains(&full_key) {
250 existing_keys.push(full_key);
251 continue;
252 }
253 preview.push(full_key.clone());
254 if request.apply {
255 added.push(full_key.clone());
256 let section = item
257 .section
258 .as_deref()
259 .map(sanitize_section)
260 .unwrap_or_else(|| "suggested".to_string());
261 by_section
262 .entry(section)
263 .or_default()
264 .push((full_key, item));
265 }
266 }
267
268 if request.apply && !by_section.is_empty() {
269 append_to_keys_ini(
270 &root,
271 &suggestion_id,
272 &request.source,
273 &request.reason,
274 by_section,
275 )?;
276 }
277
278 let suggestions = suggestions_path(&root);
279 append_suggestion_record(
280 &root,
281 &suggestion_id,
282 &request,
283 &preview,
284 &added,
285 &existing_keys,
286 )?;
287
288 let receipt = if request.apply {
289 Some(write_receipt(
290 &root,
291 &suggestion_id,
292 &request,
293 &added,
294 &existing_keys,
295 )?)
296 } else {
297 None
298 };
299
300 Ok(SuggestKeysReport {
301 suggestion_id,
302 root: root.clone(),
303 keys_path: keys_path(&root),
304 suggestions_path: suggestions,
305 receipt_path: receipt,
306 namespace,
307 added_keys: added,
308 existing_keys,
309 preview_keys: preview,
310 applied: request.apply,
311 vault_values_written: false,
312 })
313}
314
315fn normalize_root(root: &Path) -> PathBuf {
316 if root.as_os_str().is_empty() {
317 PathBuf::from(".")
318 } else {
319 root.to_path_buf()
320 }
321}
322
323fn default_namespace(root: &Path) -> String {
324 let name = root
325 .file_name()
326 .and_then(|s| s.to_str())
327 .filter(|s| !s.trim().is_empty())
328 .unwrap_or("repo");
329 format!("{}/", sanitize_key_part(name))
330}
331
332fn normalize_namespace(raw: &str) -> Result<String> {
333 let trimmed = raw.trim().trim_start_matches('/').replace('\\', "/");
334 if trimmed.is_empty() {
335 return Err(ToolingInventoryError::InvalidInput(
336 "namespace must not be empty".to_string(),
337 ));
338 }
339 let namespace = if trimmed.ends_with('/') {
340 trimmed
341 } else {
342 format!("{trimmed}/")
343 };
344 if namespace.contains("//")
345 || namespace
346 .trim_end_matches('/')
347 .split('/')
348 .any(|part| part.trim().is_empty())
349 {
350 return Err(ToolingInventoryError::InvalidInput(format!(
351 "namespace '{namespace}' is not valid"
352 )));
353 }
354 validate_secret_key(namespace.trim_end_matches('/')).map_err(|err| {
355 ToolingInventoryError::InvalidInput(format!("namespace '{namespace}' is not valid: {err}"))
356 })?;
357 Ok(namespace)
358}
359
360fn normalize_suggested_key(namespace: &str, raw: &str) -> Result<String> {
361 let key = raw.trim().trim_start_matches('/').replace('\\', "/");
362 if key.is_empty() {
363 return Err(ToolingInventoryError::InvalidInput(
364 "suggested key must not be empty".to_string(),
365 ));
366 }
367 let full = if key.starts_with(namespace) {
368 key
369 } else if key.contains('/') {
370 return Err(ToolingInventoryError::InvalidInput(format!(
371 "suggested key '{key}' is outside namespace '{namespace}'"
372 )));
373 } else {
374 format!("{namespace}{}", sanitize_key_part(&key))
375 };
376 if full.contains("//") || full.ends_with('/') {
377 return Err(ToolingInventoryError::InvalidInput(format!(
378 "suggested key '{full}' is not valid"
379 )));
380 }
381 validate_secret_key(&full).map_err(|err| {
382 ToolingInventoryError::InvalidInput(format!("suggested key '{full}' is not valid: {err}"))
383 })?;
384 Ok(full)
385}
386
387fn sanitize_key_part(raw: &str) -> String {
388 raw.chars()
389 .map(|ch| {
390 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
391 ch
392 } else {
393 '_'
394 }
395 })
396 .collect()
397}
398
399fn sanitize_section(raw: &str) -> String {
400 let section: String = raw
401 .trim()
402 .chars()
403 .map(|ch| {
404 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
405 ch
406 } else {
407 '-'
408 }
409 })
410 .filter(|ch| !ch.is_whitespace())
411 .collect();
412 if section.is_empty() {
413 "suggested".to_string()
414 } else {
415 section
416 }
417}
418
419fn validate_metadata_field(label: &str, raw: &str) -> Result<()> {
420 let value = raw.trim();
421 if value.is_empty() {
422 return Err(ToolingInventoryError::InvalidInput(format!(
423 "{label} must not be empty"
424 )));
425 }
426 if value.contains('|') || value.chars().any(|ch| ch.is_control()) {
427 return Err(ToolingInventoryError::InvalidInput(format!(
428 "{label} must not contain control characters or `|`"
429 )));
430 }
431 Ok(())
432}
433
434fn validate_comment_field(label: &str, raw: &str) -> Result<()> {
435 let value = raw.trim();
436 if value.is_empty() {
437 return Err(ToolingInventoryError::InvalidInput(format!(
438 "{label} must not be empty"
439 )));
440 }
441 if value.chars().any(|ch| ch.is_control()) {
442 return Err(ToolingInventoryError::InvalidInput(format!(
443 "{label} must not contain control characters"
444 )));
445 }
446 Ok(())
447}
448
449fn render_keys_ini(root: &Path, namespace: &str) -> String {
450 let repo = root.file_name().and_then(|s| s.to_str()).unwrap_or("repo");
451 format!(
452 r#"# tsafe secret inventory for {repo}
453# Namespace: {namespace}
454# Format: key = purpose | consumer | rotation
455# Values never belong in this file.
456
457[inventory]
458schema = {KEYS_SCHEMA}
459namespace = {namespace}
460
461[ci-cd-spn]
462# key/name = purpose | consumer | rotation
463# {namespace}ci_cd_app_id = SPN app ID | CI service connection | static
464
465[sql-bridge]
466# {namespace}sql_password = SQL password | runtime bridge | 365d KV policy
467
468[scim]
469# {namespace}scim_pat = SCIM PAT | provisioning app | manual rotation on expiry
470
471[ops-read-only]
472# {namespace}readonly_app_id = read-only SPN app ID | discovery scripts | static
473"#
474 )
475}
476
477fn render_policy(namespace: &str) -> String {
478 format!(
479 r#"schema = "{POLICY_SCHEMA}"
480
481[namespace]
482default = "{namespace}"
483allow = ["{namespace}"]
484
485[mcp]
486suggest_enabled = true
487auto_write_keys_ini = true
488auto_write_vault_values = false
489require_receipt = true
490reject_plaintext_values = true
491"#
492 )
493}
494
495fn render_readme() -> &'static str {
496 r#"# tsafe tooling inventory
497
498This folder records secret slots for this repository. It is safe to commit when
499it contains only key names, purpose, consumer, and rotation metadata.
500
501- `keys.ini` is the human inventory.
502- `policy.toml` controls agent/MCP suggestion behavior.
503- `suggestions.jsonl` is an append-only suggestion log.
504- `receipts/` contains one JSON receipt per applied suggestion.
505
506Do not put secret values, tokens, private keys, vault files, or dotenv files in
507this folder.
508"#
509}
510
511fn parse_keys_ini(text: &str) -> ParsedInventory {
512 let mut section: Option<String> = None;
513 let mut namespace = None;
514 let mut entries = Vec::new();
515 let mut warnings = Vec::new();
516 let mut errors = Vec::new();
517 let mut seen = BTreeSet::new();
518
519 for (idx, raw_line) in text.lines().enumerate() {
520 let line_no = idx + 1;
521 let line = raw_line.trim();
522 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
523 continue;
524 }
525 if line.starts_with('[') && line.ends_with(']') {
526 let name = line.trim_start_matches('[').trim_end_matches(']').trim();
527 if name.is_empty() {
528 errors.push(format!("line {line_no}: empty section name"));
529 section = None;
530 } else {
531 section = Some(name.to_string());
532 }
533 continue;
534 }
535
536 let current_section = section.clone().unwrap_or_else(|| "default".to_string());
537 let Some((lhs, rhs)) = line.split_once('=') else {
538 errors.push(format!(
539 "line {line_no}: expected `key = purpose | consumer | rotation`"
540 ));
541 continue;
542 };
543 let key = lhs.trim();
544 let value = rhs.trim();
545
546 if current_section == "inventory" {
547 match key {
548 "schema" if value != KEYS_SCHEMA => warnings.push(format!(
549 "line {line_no}: schema '{value}' is not the current {KEYS_SCHEMA}"
550 )),
551 "namespace" => match normalize_namespace(value) {
552 Ok(ns) => namespace = Some(ns),
553 Err(err) => errors.push(format!("line {line_no}: {err}")),
554 },
555 _ => {}
556 }
557 continue;
558 }
559
560 let parts: Vec<&str> = value.split('|').map(str::trim).collect();
561 if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
562 errors.push(format!(
563 "line {line_no}: expected `key = purpose | consumer | rotation`"
564 ));
565 continue;
566 }
567 if key.is_empty() || !key.contains('/') {
568 errors.push(format!(
569 "line {line_no}: key '{key}' must include a namespace prefix"
570 ));
571 continue;
572 }
573 if let Err(err) = validate_secret_key(key) {
574 errors.push(format!("line {line_no}: key '{key}' is not valid: {err}"));
575 continue;
576 }
577 if has_plaintext_secret_shape(value) {
578 errors.push(format!(
579 "line {line_no}: row appears to contain a plaintext secret value"
580 ));
581 continue;
582 }
583 if !seen.insert(key.to_string()) {
584 errors.push(format!("line {line_no}: duplicate key '{key}'"));
585 }
586 if let Some(ns) = namespace.as_deref() {
587 if !key.starts_with(ns) {
588 errors.push(format!(
589 "line {line_no}: key '{key}' is outside namespace '{ns}'"
590 ));
591 }
592 }
593
594 entries.push(InventoryEntry {
595 section: current_section,
596 key: key.to_string(),
597 purpose: parts[0].to_string(),
598 consumer: parts[1].to_string(),
599 rotation: parts[2].to_string(),
600 line: line_no,
601 });
602 }
603
604 if namespace.is_none() {
605 warnings.push("missing [inventory] namespace".to_string());
606 }
607
608 ParsedInventory {
609 namespace,
610 entries,
611 warnings,
612 errors,
613 }
614}
615
616fn has_plaintext_secret_shape(value: &str) -> bool {
617 let v = value.trim();
618 if v.contains("-----BEGIN ") || v.contains(" PRIVATE KEY-----") {
619 return true;
620 }
621 let lower = v.to_ascii_lowercase();
622 if lower.contains("github_pat_") || lower.contains("ghp_") || lower.contains("sk-") {
623 return true;
624 }
625 v.split(|ch: char| !ch.is_ascii_alphanumeric())
626 .any(|part| part.starts_with("AKIA") && part.len() >= 16)
627}
628
629fn append_to_keys_ini(
630 root: &Path,
631 suggestion_id: &str,
632 source: &str,
633 reason: &str,
634 by_section: BTreeMap<String, Vec<(String, &SuggestKey)>>,
635) -> Result<()> {
636 let path = keys_path(root);
637 let mut file = fs::OpenOptions::new()
638 .append(true)
639 .create(true)
640 .open(&path)?;
641 writeln!(
642 file,
643 "\n# tsafe suggestion {suggestion_id}: source={source}; reason={reason}"
644 )?;
645 for (section, rows) in by_section {
646 writeln!(file, "\n[{section}]")?;
647 for (full_key, item) in rows {
648 writeln!(
649 file,
650 "{full_key} = {} | {} | {}",
651 item.purpose.trim(),
652 item.consumer.trim(),
653 item.rotation.trim()
654 )?;
655 }
656 }
657 Ok(())
658}
659
660fn append_suggestion_record(
661 root: &Path,
662 suggestion_id: &str,
663 request: &SuggestKeysRequest,
664 preview: &[String],
665 added: &[String],
666 existing: &[String],
667) -> Result<()> {
668 let path = suggestions_path(root);
669 let mut file = fs::OpenOptions::new()
670 .create(true)
671 .append(true)
672 .open(path)?;
673 let record = serde_json::json!({
674 "schema": "tsafe.tooling.suggestion.v1",
675 "suggestion_id": suggestion_id,
676 "created_at": Utc::now().to_rfc3339(),
677 "source": request.source,
678 "reason": request.reason,
679 "apply": request.apply,
680 "preview_keys": preview,
681 "added_keys": added,
682 "existing_keys": existing,
683 "vault_values_written": false,
684 });
685 writeln!(file, "{}", serde_json::to_string(&record)?)?;
686 Ok(())
687}
688
689fn write_receipt(
690 root: &Path,
691 suggestion_id: &str,
692 request: &SuggestKeysRequest,
693 added: &[String],
694 existing: &[String],
695) -> Result<PathBuf> {
696 let dir = receipts_dir(root);
697 fs::create_dir_all(&dir)?;
698 let path = dir.join(format!("{suggestion_id}.json"));
699 let receipt = serde_json::json!({
700 "schema": "tsafe.tooling.receipt.v1",
701 "suggestion_id": suggestion_id,
702 "created_at": Utc::now().to_rfc3339(),
703 "source": request.source,
704 "reason": request.reason,
705 "added_keys": added,
706 "existing_keys": existing,
707 "keys_ini": keys_path(root),
708 "vault_values_written": false,
709 });
710 fs::write(&path, serde_json::to_string_pretty(&receipt)?)?;
711 Ok(path)
712}