1#![allow(clippy::field_reassign_with_default)]
2use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Clone, Debug, Default)]
13pub struct TfManifestPaths {
14 pub root_dir: PathBuf,
15 pub agent_contract: Option<PathBuf>,
16 pub threat_model: Option<PathBuf>,
17 pub policy: Option<PathBuf>,
18 pub actions: Option<PathBuf>,
19 pub proof_profile: Option<PathBuf>,
20 pub codegen: Option<PathBuf>,
21 pub conformance: Option<PathBuf>,
22}
23
24#[derive(Clone, Debug, Default, Serialize, Deserialize)]
25pub struct TfManifests {
26 pub agent_contract: Option<Value>,
27 pub threat_model: Option<Value>,
28 pub policy: Option<Value>,
29 pub actions: Option<Value>,
30 pub proof_profile: Option<Value>,
31 pub codegen: Option<HashMap<String, String>>,
32 pub conformance: Option<Value>,
33 pub diagnostics: Vec<TfDiagnostic>,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct TfDiagnostic {
38 pub file: String,
39 pub reason: String,
40}
41
42#[derive(Clone, Debug, Default)]
43pub struct TfFeatureGate {
44 pub policy: Option<Value>,
45 pub claimed_profiles: Vec<String>,
46 pub default_proof_level: Option<String>,
47 pub anchors: Vec<Value>,
48 pub forbidden_actions: Vec<String>,
49 pub per_action_proof_level: HashMap<String, String>,
50}
51
52impl TfFeatureGate {
53 pub fn proof_level_for_action(&self, action: &str) -> Option<&str> {
54 self.per_action_proof_level.get(action).map(String::as_str)
55 }
56}
57
58const REL_AGENT_CONTRACT: &str = ".tf/agent-contract.yaml";
59const REL_THREAT_MODEL: &str = ".tf/threat-model.yaml";
60const REL_POLICY: &str = ".tf/policy.yaml";
61const REL_ACTIONS: &str = ".tf/actions.yaml";
62const REL_PROOF_PROFILE: &str = ".tf/proof-profile.yaml";
63const REL_CODEGEN: &str = ".tf/codegen.toml";
64const REL_CONFORMANCE: &str = ".tf/conformance.json";
65
66pub fn load_tf_manifests(paths: &TfManifestPaths) -> TfManifests {
67 let mut out = TfManifests::default();
68 let try_yaml = |path: &Path, target: &mut Option<Value>, diags: &mut Vec<TfDiagnostic>| {
69 if !path.exists() {
70 return;
71 }
72 match fs::read_to_string(path)
73 .map_err(|e| e.to_string())
74 .and_then(|raw| crate::yaml::parse(&raw).map_err(|e| e.to_string()))
75 {
76 Ok(v) => *target = Some(v),
77 Err(reason) => diags.push(TfDiagnostic {
78 file: path.display().to_string(),
79 reason,
80 }),
81 }
82 };
83 let try_json = |path: &Path, target: &mut Option<Value>, diags: &mut Vec<TfDiagnostic>| {
84 if !path.exists() {
85 return;
86 }
87 match fs::read_to_string(path)
88 .map_err(|e| e.to_string())
89 .and_then(|raw| serde_json::from_str::<Value>(&raw).map_err(|e| e.to_string()))
90 {
91 Ok(v) => *target = Some(v),
92 Err(reason) => diags.push(TfDiagnostic {
93 file: path.display().to_string(),
94 reason,
95 }),
96 }
97 };
98
99 let agent = paths
100 .agent_contract
101 .clone()
102 .unwrap_or_else(|| paths.root_dir.join(REL_AGENT_CONTRACT));
103 try_yaml(&agent, &mut out.agent_contract, &mut out.diagnostics);
104 let tm = paths
105 .threat_model
106 .clone()
107 .unwrap_or_else(|| paths.root_dir.join(REL_THREAT_MODEL));
108 try_yaml(&tm, &mut out.threat_model, &mut out.diagnostics);
109 let policy = paths
110 .policy
111 .clone()
112 .unwrap_or_else(|| paths.root_dir.join(REL_POLICY));
113 try_yaml(&policy, &mut out.policy, &mut out.diagnostics);
114 let actions = paths
115 .actions
116 .clone()
117 .unwrap_or_else(|| paths.root_dir.join(REL_ACTIONS));
118 try_yaml(&actions, &mut out.actions, &mut out.diagnostics);
119 let pp = paths
120 .proof_profile
121 .clone()
122 .unwrap_or_else(|| paths.root_dir.join(REL_PROOF_PROFILE));
123 try_yaml(&pp, &mut out.proof_profile, &mut out.diagnostics);
124 let cf = paths
125 .conformance
126 .clone()
127 .unwrap_or_else(|| paths.root_dir.join(REL_CONFORMANCE));
128 try_json(&cf, &mut out.conformance, &mut out.diagnostics);
129
130 let cg = paths
131 .codegen
132 .clone()
133 .unwrap_or_else(|| paths.root_dir.join(REL_CODEGEN));
134 if cg.exists() {
135 match fs::read_to_string(&cg) {
136 Ok(raw) => out.codegen = Some(parse_tiny_toml(&raw)),
137 Err(e) => out.diagnostics.push(TfDiagnostic {
138 file: cg.display().to_string(),
139 reason: e.to_string(),
140 }),
141 }
142 }
143 out
144}
145
146pub fn build_feature_gate(manifests: &TfManifests) -> TfFeatureGate {
147 let mut gate = TfFeatureGate::default();
148 gate.policy = manifests.policy.clone();
149 if let Some(conf) = manifests.conformance.as_ref() {
150 if let Some(arr) = conf.get("claimed_profiles").and_then(|v| v.as_array()) {
151 gate.claimed_profiles = arr
152 .iter()
153 .filter_map(|v| v.as_str().map(str::to_string))
154 .collect();
155 }
156 }
157 if let Some(pp) = manifests.proof_profile.as_ref() {
158 gate.default_proof_level = pp
159 .get("default_proof_level")
160 .and_then(|v| v.as_str())
161 .map(str::to_string)
162 .or_else(|| {
163 pp.get("default_level")
164 .and_then(|v| v.as_str())
165 .map(str::to_string)
166 });
167 if let Some(actions) = pp.get("actions").and_then(|v| v.as_array()) {
168 for a in actions {
169 if let (Some(name), Some(level)) = (
170 a.get("name").and_then(|v| v.as_str()),
171 a.get("level").and_then(|v| v.as_str()),
172 ) {
173 gate.per_action_proof_level
174 .insert(name.to_string(), level.to_string());
175 }
176 }
177 }
178 if let Some(anchors) = pp.get("anchors").and_then(|v| v.as_array()) {
179 gate.anchors = anchors.clone();
180 }
181 }
182 if let Some(ac) = manifests.agent_contract.as_ref() {
183 if let Some(forbidden) = ac.get("forbidden").and_then(|v| v.as_array()) {
184 for f in forbidden {
185 if let Some(name) = f.get("action").and_then(|v| v.as_str()) {
186 gate.forbidden_actions.push(name.to_string());
187 }
188 }
189 }
190 }
191 gate
192}
193
194fn parse_tiny_toml(raw: &str) -> HashMap<String, String> {
195 let mut out = HashMap::new();
196 for line in raw.lines() {
197 let stripped = line.split('#').next().unwrap_or(line).trim();
198 if stripped.is_empty() {
199 continue;
200 }
201 if let Some((k, v)) = stripped.split_once('=') {
202 let key = k.trim();
203 let mut value = v.trim();
204 if (value.starts_with('"') && value.ends_with('"'))
205 || (value.starts_with('\'') && value.ends_with('\''))
206 {
207 value = &value[1..value.len() - 1];
208 }
209 out.insert(key.to_string(), value.to_string());
210 }
211 }
212 out
213}