1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::Profile;
7use crate::error::{AvError, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct TeamConfig {
12 pub source: Option<String>,
16 pub cache_ttl: Option<u64>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22struct TeamPolicies {
23 #[serde(default)]
24 policies: HashMap<String, Profile>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29struct CacheEntry {
30 fetched_at: i64,
31 policies: HashMap<String, Profile>,
32}
33
34fn cache_path() -> PathBuf {
35 dirs::cache_dir()
36 .unwrap_or_else(|| PathBuf::from("."))
37 .join("audex")
38 .join("team_policies.json")
39}
40
41fn load_cache(ttl_secs: u64) -> Option<HashMap<String, Profile>> {
43 let path = cache_path();
44 let raw = std::fs::read_to_string(&path).ok()?;
45
46 let (stored_hmac, json) = raw.split_once('\n')?;
48 let computed = cache_hmac(json);
49 if stored_hmac != computed {
50 eprintln!(" \x1b[33m⚠\x1b[0m Team policy cache integrity check failed — ignoring cache");
51 let _ = std::fs::remove_file(&path);
52 return None;
53 }
54
55 let entry: CacheEntry = serde_json::from_str(json).ok()?;
56 let now = chrono::Utc::now().timestamp();
57 if (now - entry.fetched_at) < ttl_secs as i64 {
58 Some(entry.policies)
59 } else {
60 None
61 }
62}
63
64fn cache_hmac(data: &str) -> String {
66 use hmac::{Hmac, Mac};
67 use sha2::Sha256;
68 let key = crate::integrity::get_hmac_key();
69 let mut mac = Hmac::<Sha256>::new_from_slice(&key).expect("HMAC accepts any key length");
70 mac.update(data.as_bytes());
71 hex::encode(mac.finalize().into_bytes())
72}
73
74fn save_cache(policies: &HashMap<String, Profile>) {
76 let entry = CacheEntry {
77 fetched_at: chrono::Utc::now().timestamp(),
78 policies: policies.clone(),
79 };
80 if let Ok(json) = serde_json::to_string(&entry) {
81 let hmac = cache_hmac(&json);
82 let signed = format!("{}\n{}", hmac, json);
83 let path = cache_path();
84 if let Some(parent) = path.parent() {
85 let _ = std::fs::create_dir_all(parent);
86 }
87 #[cfg(unix)]
88 {
89 use std::io::Write;
90 use std::os::unix::fs::OpenOptionsExt;
91 let _ = std::fs::OpenOptions::new()
92 .write(true)
93 .create(true)
94 .truncate(true)
95 .mode(0o600)
96 .open(&path)
97 .and_then(|mut f| f.write_all(signed.as_bytes()));
98 }
99 #[cfg(not(unix))]
100 {
101 let _ = std::fs::write(&path, &signed);
102 }
103 }
104}
105
106pub async fn fetch(config: &TeamConfig) -> Result<HashMap<String, Profile>> {
108 let source = config.source.as_deref().ok_or_else(|| {
109 AvError::InvalidPolicy(
110 "No team policy source configured. Set [team] source = \"https://...\" in config.toml"
111 .to_string(),
112 )
113 })?;
114
115 if !source.starts_with("https://") {
116 return Err(AvError::InvalidPolicy(
117 "Team policy source must use https://".to_string(),
118 ));
119 }
120
121 let ttl = config.cache_ttl.unwrap_or(3600);
122
123 if let Some(cached) = load_cache(ttl) {
125 return Ok(cached);
126 }
127
128 let client = reqwest::Client::new();
130 let resp = client
131 .get(source)
132 .timeout(std::time::Duration::from_secs(10))
133 .send()
134 .await
135 .map_err(|e| {
136 AvError::InvalidPolicy(format!(
137 "Failed to fetch team policies from {}: {}",
138 source, e
139 ))
140 })?;
141
142 if !resp.status().is_success() {
143 return Err(AvError::InvalidPolicy(format!(
144 "Team policy source returned {}: {}",
145 resp.status(),
146 source
147 )));
148 }
149
150 const MAX_TEAM_POLICY_BYTES: usize = 1024 * 1024; let mut resp = resp;
157 let mut body_bytes: Vec<u8> = Vec::new();
158 loop {
159 match resp.chunk().await {
160 Ok(Some(chunk)) => {
161 if body_bytes.len() + chunk.len() > MAX_TEAM_POLICY_BYTES {
162 return Err(AvError::InvalidPolicy(format!(
163 "Team policy response exceeds {} byte cap from {}",
164 MAX_TEAM_POLICY_BYTES, source
165 )));
166 }
167 body_bytes.extend_from_slice(&chunk);
168 }
169 Ok(None) => break,
170 Err(e) => {
171 return Err(AvError::InvalidPolicy(format!(
172 "Failed to read team policies: {}",
173 e
174 )));
175 }
176 }
177 }
178 let body = String::from_utf8(body_bytes).map_err(|e| {
179 AvError::InvalidPolicy(format!("Team policy response is not valid UTF-8: {}", e))
180 })?;
181
182 let team_policies: TeamPolicies = toml::from_str(&body)
183 .map_err(|e| AvError::InvalidPolicy(format!("Invalid team policies TOML: {}", e)))?;
184
185 save_cache(&team_policies.policies);
187
188 Ok(team_policies.policies)
189}
190
191pub async fn resolve(config: &TeamConfig, name: &str) -> Result<Profile> {
193 let key = name.strip_prefix("team://").unwrap_or(name);
194 let policies = fetch(config).await?;
195
196 policies.get(key).cloned().ok_or_else(|| {
197 let available: Vec<String> = policies.keys().cloned().collect();
198 AvError::InvalidPolicy(format!(
199 "Unknown team policy '{}'. Available: {}",
200 key,
201 if available.is_empty() {
202 "(none)".to_string()
203 } else {
204 available.join(", ")
205 }
206 ))
207 })
208}
209
210pub fn resolve_cached(name: &str) -> Result<Profile> {
212 let key = name.strip_prefix("team://").unwrap_or(name);
213 let ttl = 3600; let policies = load_cache(ttl).ok_or_else(|| {
216 AvError::InvalidPolicy(
217 "No cached team policies. Run `tryaudex run` once to fetch them.".to_string(),
218 )
219 })?;
220
221 policies
222 .get(key)
223 .cloned()
224 .ok_or_else(|| AvError::InvalidPolicy(format!("Unknown team policy '{}'", key)))
225}
226
227pub fn list_cached() -> Vec<(String, Profile)> {
229 load_cache(u64::MAX)
230 .unwrap_or_default()
231 .into_iter()
232 .collect()
233}