Skip to main content

sentinel_proxy/bundle/
lock.rs

1//! Bundle lock file parsing
2//!
3//! Parses the `bundle-versions.lock` TOML file that defines which agent
4//! versions are included in the bundle.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9use thiserror::Error;
10
11/// Errors that can occur when parsing the lock file
12#[derive(Debug, Error)]
13pub enum LockError {
14    #[error("Failed to read lock file: {0}")]
15    Io(#[from] std::io::Error),
16
17    #[error("Failed to parse lock file: {0}")]
18    Parse(#[from] toml::de::Error),
19
20    #[error("Lock file not found at: {0}")]
21    NotFound(String),
22
23    #[error("Failed to fetch lock file from remote: {0}")]
24    Fetch(String),
25}
26
27/// Bundle lock file structure
28#[derive(Debug, Clone, Deserialize)]
29pub struct BundleLock {
30    /// Bundle metadata
31    pub bundle: BundleInfo,
32
33    /// Agent versions (agent name -> version)
34    pub agents: HashMap<String, String>,
35
36    /// Agent repositories (agent name -> "owner/repo")
37    pub repositories: HashMap<String, String>,
38
39    /// Optional checksums for verification
40    #[serde(default)]
41    pub checksums: HashMap<String, String>,
42}
43
44/// Bundle metadata
45#[derive(Debug, Clone, Deserialize)]
46pub struct BundleInfo {
47    /// Bundle version (CalVer: YY.MM_PATCH)
48    pub version: String,
49}
50
51/// Information about a bundled agent
52#[derive(Debug, Clone)]
53pub struct AgentInfo {
54    /// Agent name (e.g., "waf", "ratelimit")
55    pub name: String,
56
57    /// Version string (e.g., "0.2.0")
58    pub version: String,
59
60    /// GitHub repository (e.g., "raskell-io/sentinel-agent-waf")
61    pub repository: String,
62
63    /// Binary name (e.g., "sentinel-waf-agent")
64    pub binary_name: String,
65}
66
67impl BundleLock {
68    /// Load the embedded lock file (compiled into the binary)
69    pub fn embedded() -> Result<Self, LockError> {
70        let content = include_str!(concat!(env!("OUT_DIR"), "/bundle-versions.lock"));
71        Self::from_str(content)
72    }
73
74    /// Load lock file from a path
75    pub fn from_file(path: &Path) -> Result<Self, LockError> {
76        if !path.exists() {
77            return Err(LockError::NotFound(path.display().to_string()));
78        }
79        let content = std::fs::read_to_string(path)?;
80        Self::from_str(&content)
81    }
82
83    /// Parse lock file from string content
84    pub fn from_str(content: &str) -> Result<Self, LockError> {
85        let lock: BundleLock = toml::from_str(content)?;
86        Ok(lock)
87    }
88
89    /// Fetch the latest lock file from the repository
90    pub async fn fetch_latest() -> Result<Self, LockError> {
91        let url = "https://raw.githubusercontent.com/raskell-io/sentinel/main/bundle-versions.lock";
92
93        let client = reqwest::Client::new();
94        let response = client
95            .get(url)
96            .header("User-Agent", "sentinel-bundle")
97            .send()
98            .await
99            .map_err(|e| LockError::Fetch(e.to_string()))?;
100
101        if !response.status().is_success() {
102            return Err(LockError::Fetch(format!(
103                "HTTP {} from {}",
104                response.status(),
105                url
106            )));
107        }
108
109        let content = response
110            .text()
111            .await
112            .map_err(|e| LockError::Fetch(e.to_string()))?;
113
114        Self::from_str(&content)
115    }
116
117    /// Get information about all bundled agents
118    pub fn agents(&self) -> Vec<AgentInfo> {
119        self.agents
120            .iter()
121            .filter_map(|(name, version)| {
122                let repository = self.repositories.get(name)?;
123                Some(AgentInfo {
124                    name: name.clone(),
125                    version: version.clone(),
126                    repository: repository.clone(),
127                    binary_name: format!("sentinel-{}-agent", name),
128                })
129            })
130            .collect()
131    }
132
133    /// Get information about a specific agent
134    pub fn agent(&self, name: &str) -> Option<AgentInfo> {
135        let version = self.agents.get(name)?;
136        let repository = self.repositories.get(name)?;
137        Some(AgentInfo {
138            name: name.to_string(),
139            version: version.clone(),
140            repository: repository.clone(),
141            binary_name: format!("sentinel-{}-agent", name),
142        })
143    }
144
145    /// Get the list of agent names
146    pub fn agent_names(&self) -> Vec<&str> {
147        self.agents.keys().map(|s| s.as_str()).collect()
148    }
149}
150
151impl AgentInfo {
152    /// Get the download URL for this agent
153    ///
154    /// # Arguments
155    /// * `os` - Operating system (e.g., "linux", "darwin")
156    /// * `arch` - Architecture (e.g., "amd64", "arm64")
157    pub fn download_url(&self, os: &str, arch: &str) -> String {
158        // Map our arch names to release artifact naming conventions
159        let release_arch = match arch {
160            "amd64" => "x86_64",
161            "arm64" => "aarch64",
162            _ => arch,
163        };
164
165        format!(
166            "https://github.com/{}/releases/download/v{}/{}-{}-{}-{}.tar.gz",
167            self.repository, self.version, self.binary_name, self.version, os, release_arch
168        )
169    }
170
171    /// Get the checksum URL for this agent
172    pub fn checksum_url(&self, os: &str, arch: &str) -> String {
173        format!("{}.sha256", self.download_url(os, arch))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_parse_lock_file() {
183        let content = r#"
184[bundle]
185version = "26.01_1"
186
187[agents]
188waf = "0.2.0"
189ratelimit = "0.2.0"
190
191[repositories]
192waf = "raskell-io/sentinel-agent-waf"
193ratelimit = "raskell-io/sentinel-agent-ratelimit"
194"#;
195
196        let lock = BundleLock::from_str(content).unwrap();
197        assert_eq!(lock.bundle.version, "26.01_1");
198        assert_eq!(lock.agents.get("waf"), Some(&"0.2.0".to_string()));
199        assert_eq!(lock.agents.get("ratelimit"), Some(&"0.2.0".to_string()));
200    }
201
202    #[test]
203    fn test_parse_lock_file_with_checksums() {
204        let content = r#"
205[bundle]
206version = "26.01_2"
207
208[agents]
209waf = "0.3.0"
210
211[repositories]
212waf = "raskell-io/sentinel-agent-waf"
213
214[checksums]
215waf = "abc123def456"
216"#;
217
218        let lock = BundleLock::from_str(content).unwrap();
219        assert_eq!(lock.checksums.get("waf"), Some(&"abc123def456".to_string()));
220    }
221
222    #[test]
223    fn test_parse_lock_file_empty_checksums() {
224        let content = r#"
225[bundle]
226version = "26.01_1"
227
228[agents]
229waf = "0.2.0"
230
231[repositories]
232waf = "raskell-io/sentinel-agent-waf"
233"#;
234
235        let lock = BundleLock::from_str(content).unwrap();
236        assert!(lock.checksums.is_empty());
237    }
238
239    #[test]
240    fn test_parse_invalid_toml() {
241        let content = "this is not valid toml {{{";
242        let result = BundleLock::from_str(content);
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn test_parse_missing_bundle_section() {
248        let content = r#"
249[agents]
250waf = "0.2.0"
251
252[repositories]
253waf = "raskell-io/sentinel-agent-waf"
254"#;
255        let result = BundleLock::from_str(content);
256        assert!(result.is_err());
257    }
258
259    #[test]
260    fn test_agent_info() {
261        let content = r#"
262[bundle]
263version = "26.01_1"
264
265[agents]
266waf = "0.2.0"
267
268[repositories]
269waf = "raskell-io/sentinel-agent-waf"
270"#;
271
272        let lock = BundleLock::from_str(content).unwrap();
273        let agent = lock.agent("waf").unwrap();
274
275        assert_eq!(agent.name, "waf");
276        assert_eq!(agent.version, "0.2.0");
277        assert_eq!(agent.binary_name, "sentinel-waf-agent");
278
279        let url = agent.download_url("linux", "amd64");
280        assert!(url.contains("sentinel-waf-agent"));
281        assert!(url.contains("v0.2.0"));
282        assert!(url.contains("x86_64"));
283    }
284
285    #[test]
286    fn test_agent_not_found() {
287        let content = r#"
288[bundle]
289version = "26.01_1"
290
291[agents]
292waf = "0.2.0"
293
294[repositories]
295waf = "raskell-io/sentinel-agent-waf"
296"#;
297
298        let lock = BundleLock::from_str(content).unwrap();
299        assert!(lock.agent("nonexistent").is_none());
300    }
301
302    #[test]
303    fn test_agent_without_repository() {
304        let content = r#"
305[bundle]
306version = "26.01_1"
307
308[agents]
309waf = "0.2.0"
310orphan = "1.0.0"
311
312[repositories]
313waf = "raskell-io/sentinel-agent-waf"
314"#;
315
316        let lock = BundleLock::from_str(content).unwrap();
317        // orphan has no repository entry, so agent() should return None
318        assert!(lock.agent("orphan").is_none());
319        // agents() should skip orphan
320        let agents = lock.agents();
321        assert_eq!(agents.len(), 1);
322        assert_eq!(agents[0].name, "waf");
323    }
324
325    #[test]
326    fn test_agent_names() {
327        let content = r#"
328[bundle]
329version = "26.01_1"
330
331[agents]
332waf = "0.2.0"
333ratelimit = "0.2.0"
334denylist = "0.2.0"
335
336[repositories]
337waf = "raskell-io/sentinel-agent-waf"
338ratelimit = "raskell-io/sentinel-agent-ratelimit"
339denylist = "raskell-io/sentinel-agent-denylist"
340"#;
341
342        let lock = BundleLock::from_str(content).unwrap();
343        let names = lock.agent_names();
344        assert_eq!(names.len(), 3);
345        assert!(names.contains(&"waf"));
346        assert!(names.contains(&"ratelimit"));
347        assert!(names.contains(&"denylist"));
348    }
349
350    #[test]
351    fn test_download_url_linux_amd64() {
352        let agent = AgentInfo {
353            name: "waf".to_string(),
354            version: "0.2.0".to_string(),
355            repository: "raskell-io/sentinel-agent-waf".to_string(),
356            binary_name: "sentinel-waf-agent".to_string(),
357        };
358
359        let url = agent.download_url("linux", "amd64");
360        assert_eq!(
361            url,
362            "https://github.com/raskell-io/sentinel-agent-waf/releases/download/v0.2.0/sentinel-waf-agent-0.2.0-linux-x86_64.tar.gz"
363        );
364    }
365
366    #[test]
367    fn test_download_url_linux_arm64() {
368        let agent = AgentInfo {
369            name: "ratelimit".to_string(),
370            version: "1.0.0".to_string(),
371            repository: "raskell-io/sentinel-agent-ratelimit".to_string(),
372            binary_name: "sentinel-ratelimit-agent".to_string(),
373        };
374
375        let url = agent.download_url("linux", "arm64");
376        assert_eq!(
377            url,
378            "https://github.com/raskell-io/sentinel-agent-ratelimit/releases/download/v1.0.0/sentinel-ratelimit-agent-1.0.0-linux-aarch64.tar.gz"
379        );
380    }
381
382    #[test]
383    fn test_download_url_darwin() {
384        let agent = AgentInfo {
385            name: "denylist".to_string(),
386            version: "0.5.0".to_string(),
387            repository: "raskell-io/sentinel-agent-denylist".to_string(),
388            binary_name: "sentinel-denylist-agent".to_string(),
389        };
390
391        let url = agent.download_url("darwin", "arm64");
392        assert!(url.contains("darwin"));
393        assert!(url.contains("aarch64"));
394    }
395
396    #[test]
397    fn test_checksum_url() {
398        let agent = AgentInfo {
399            name: "waf".to_string(),
400            version: "0.2.0".to_string(),
401            repository: "raskell-io/sentinel-agent-waf".to_string(),
402            binary_name: "sentinel-waf-agent".to_string(),
403        };
404
405        let url = agent.checksum_url("linux", "amd64");
406        assert!(url.ends_with(".sha256"));
407        assert!(url.contains("sentinel-waf-agent"));
408    }
409
410    #[test]
411    fn test_embedded_lock() {
412        // This test verifies the embedded lock file can be parsed
413        let lock = BundleLock::embedded().unwrap();
414        assert!(!lock.bundle.version.is_empty());
415        assert!(!lock.agents.is_empty());
416    }
417
418    #[test]
419    fn test_embedded_lock_has_required_agents() {
420        let lock = BundleLock::embedded().unwrap();
421
422        // Core agents
423        assert!(lock.agent("waf").is_some(), "waf agent should be in bundle");
424        assert!(lock.agent("ratelimit").is_some(), "ratelimit agent should be in bundle");
425        assert!(lock.agent("denylist").is_some(), "denylist agent should be in bundle");
426
427        // Security agents
428        assert!(lock.agent("sentinelsec").is_some(), "sentinelsec agent should be in bundle");
429        assert!(lock.agent("ip-reputation").is_some(), "ip-reputation agent should be in bundle");
430
431        // Scripting agents
432        assert!(lock.agent("lua").is_some(), "lua agent should be in bundle");
433        assert!(lock.agent("js").is_some(), "js agent should be in bundle");
434        assert!(lock.agent("wasm").is_some(), "wasm agent should be in bundle");
435
436        // Should have many agents total
437        assert!(lock.agents.len() >= 20, "bundle should have at least 20 agents");
438    }
439
440    #[test]
441    fn test_from_file_not_found() {
442        let result = BundleLock::from_file(Path::new("/nonexistent/path/lock.toml"));
443        assert!(matches!(result, Err(LockError::NotFound(_))));
444    }
445}