Skip to main content

tryaudex_core/
team.rs

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/// Team policy configuration in `[team]` config section.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct TeamConfig {
12    /// URL to fetch team policies from.
13    /// Supports https:// URLs pointing to a TOML file.
14    /// Example: "https://raw.githubusercontent.com/org/policies/main/audex-policies.toml"
15    pub source: Option<String>,
16    /// Cache TTL in seconds (default: 3600 = 1 hour)
17    pub cache_ttl: Option<u64>,
18}
19
20/// Team policies TOML format — fetched from the remote source.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct TeamPolicies {
23    #[serde(default)]
24    policies: HashMap<String, Profile>,
25}
26
27/// Cache entry stored on disk.
28#[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
41/// Load cached team policies if still valid and integrity check passes.
42fn 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    // Verify HMAC integrity — format is "hmac_hex\njson"
47    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
64/// Compute HMAC-SHA256 of cache content for integrity verification.
65fn 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
74/// Save team policies to cache with HMAC integrity signature.
75fn 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
106/// Fetch team policies from the configured source URL.
107pub 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    // Check cache first
124    if let Some(cached) = load_cache(ttl) {
125        return Ok(cached);
126    }
127
128    // Fetch from URL
129    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    // R6-M30: cap team policy fetch body size. A malicious or compromised
151    // team policy server could otherwise return a multi-GB body (or stream
152    // indefinitely) and OOM the audex client. resp.text() would eagerly
153    // read the whole body with no limit. Read chunks with a running byte
154    // count instead and bail out at the cap.
155    const MAX_TEAM_POLICY_BYTES: usize = 1024 * 1024; // 1 MiB
156    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    // Cache for next time
186    save_cache(&team_policies.policies);
187
188    Ok(team_policies.policies)
189}
190
191/// Resolve a team policy by name. Requires async fetch.
192pub 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
210/// Resolve from cache only (sync, for use in non-async contexts).
211pub fn resolve_cached(name: &str) -> Result<Profile> {
212    let key = name.strip_prefix("team://").unwrap_or(name);
213    let ttl = 3600; // default 1 hour
214
215    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
227/// List cached team policies (sync).
228pub fn list_cached() -> Vec<(String, Profile)> {
229    load_cache(u64::MAX)
230        .unwrap_or_default()
231        .into_iter()
232        .collect()
233}