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    #[allow(clippy::should_implement_trait)]
85    pub fn from_str(content: &str) -> Result<Self, LockError> {
86        let lock: BundleLock = toml::from_str(content)?;
87        Ok(lock)
88    }
89
90    /// Fetch the latest lock file from the repository
91    pub async fn fetch_latest() -> Result<Self, LockError> {
92        let url = "https://raw.githubusercontent.com/raskell-io/sentinel/main/bundle-versions.lock";
93
94        let client = reqwest::Client::new();
95        let response = client
96            .get(url)
97            .header("User-Agent", "sentinel-bundle")
98            .send()
99            .await
100            .map_err(|e| LockError::Fetch(e.to_string()))?;
101
102        if !response.status().is_success() {
103            return Err(LockError::Fetch(format!(
104                "HTTP {} from {}",
105                response.status(),
106                url
107            )));
108        }
109
110        let content = response
111            .text()
112            .await
113            .map_err(|e| LockError::Fetch(e.to_string()))?;
114
115        Self::from_str(&content)
116    }
117
118    /// Get information about all bundled agents
119    pub fn agents(&self) -> Vec<AgentInfo> {
120        self.agents
121            .iter()
122            .filter_map(|(name, version)| {
123                let repository = self.repositories.get(name)?;
124                Some(AgentInfo {
125                    name: name.clone(),
126                    version: version.clone(),
127                    repository: repository.clone(),
128                    binary_name: format!("sentinel-{}-agent", name),
129                })
130            })
131            .collect()
132    }
133
134    /// Get information about a specific agent
135    pub fn agent(&self, name: &str) -> Option<AgentInfo> {
136        let version = self.agents.get(name)?;
137        let repository = self.repositories.get(name)?;
138        Some(AgentInfo {
139            name: name.to_string(),
140            version: version.clone(),
141            repository: repository.clone(),
142            binary_name: format!("sentinel-{}-agent", name),
143        })
144    }
145
146    /// Get the list of agent names
147    pub fn agent_names(&self) -> Vec<&str> {
148        self.agents.keys().map(|s| s.as_str()).collect()
149    }
150}
151
152impl AgentInfo {
153    /// Get the download URL for this agent
154    ///
155    /// # Arguments
156    /// * `os` - Operating system (e.g., "linux", "darwin")
157    /// * `arch` - Architecture (e.g., "amd64", "arm64")
158    pub fn download_url(&self, os: &str, arch: &str) -> String {
159        // Map our arch names to release artifact naming conventions
160        let release_arch = match arch {
161            "amd64" => "x86_64",
162            "arm64" => "aarch64",
163            _ => arch,
164        };
165
166        format!(
167            "https://github.com/{}/releases/download/v{}/{}-{}-{}-{}.tar.gz",
168            self.repository, self.version, self.binary_name, self.version, os, release_arch
169        )
170    }
171
172    /// Get the checksum URL for this agent
173    pub fn checksum_url(&self, os: &str, arch: &str) -> String {
174        format!("{}.sha256", self.download_url(os, arch))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_parse_lock_file() {
184        let content = r#"
185[bundle]
186version = "26.01_1"
187
188[agents]
189waf = "0.2.0"
190ratelimit = "0.2.0"
191
192[repositories]
193waf = "raskell-io/sentinel-agent-waf"
194ratelimit = "raskell-io/sentinel-agent-ratelimit"
195"#;
196
197        let lock = BundleLock::from_str(content).unwrap();
198        assert_eq!(lock.bundle.version, "26.01_1");
199        assert_eq!(lock.agents.get("waf"), Some(&"0.2.0".to_string()));
200        assert_eq!(lock.agents.get("ratelimit"), Some(&"0.2.0".to_string()));
201    }
202
203    #[test]
204    fn test_parse_lock_file_with_checksums() {
205        let content = r#"
206[bundle]
207version = "26.01_2"
208
209[agents]
210waf = "0.3.0"
211
212[repositories]
213waf = "raskell-io/sentinel-agent-waf"
214
215[checksums]
216waf = "abc123def456"
217"#;
218
219        let lock = BundleLock::from_str(content).unwrap();
220        assert_eq!(lock.checksums.get("waf"), Some(&"abc123def456".to_string()));
221    }
222
223    #[test]
224    fn test_parse_lock_file_empty_checksums() {
225        let content = r#"
226[bundle]
227version = "26.01_1"
228
229[agents]
230waf = "0.2.0"
231
232[repositories]
233waf = "raskell-io/sentinel-agent-waf"
234"#;
235
236        let lock = BundleLock::from_str(content).unwrap();
237        assert!(lock.checksums.is_empty());
238    }
239
240    #[test]
241    fn test_parse_invalid_toml() {
242        let content = "this is not valid toml {{{";
243        let result = BundleLock::from_str(content);
244        assert!(result.is_err());
245    }
246
247    #[test]
248    fn test_parse_missing_bundle_section() {
249        let content = r#"
250[agents]
251waf = "0.2.0"
252
253[repositories]
254waf = "raskell-io/sentinel-agent-waf"
255"#;
256        let result = BundleLock::from_str(content);
257        assert!(result.is_err());
258    }
259
260    #[test]
261    fn test_agent_info() {
262        let content = r#"
263[bundle]
264version = "26.01_1"
265
266[agents]
267waf = "0.2.0"
268
269[repositories]
270waf = "raskell-io/sentinel-agent-waf"
271"#;
272
273        let lock = BundleLock::from_str(content).unwrap();
274        let agent = lock.agent("waf").unwrap();
275
276        assert_eq!(agent.name, "waf");
277        assert_eq!(agent.version, "0.2.0");
278        assert_eq!(agent.binary_name, "sentinel-waf-agent");
279
280        let url = agent.download_url("linux", "amd64");
281        assert!(url.contains("sentinel-waf-agent"));
282        assert!(url.contains("v0.2.0"));
283        assert!(url.contains("x86_64"));
284    }
285
286    #[test]
287    fn test_agent_not_found() {
288        let content = r#"
289[bundle]
290version = "26.01_1"
291
292[agents]
293waf = "0.2.0"
294
295[repositories]
296waf = "raskell-io/sentinel-agent-waf"
297"#;
298
299        let lock = BundleLock::from_str(content).unwrap();
300        assert!(lock.agent("nonexistent").is_none());
301    }
302
303    #[test]
304    fn test_agent_without_repository() {
305        let content = r#"
306[bundle]
307version = "26.01_1"
308
309[agents]
310waf = "0.2.0"
311orphan = "1.0.0"
312
313[repositories]
314waf = "raskell-io/sentinel-agent-waf"
315"#;
316
317        let lock = BundleLock::from_str(content).unwrap();
318        // orphan has no repository entry, so agent() should return None
319        assert!(lock.agent("orphan").is_none());
320        // agents() should skip orphan
321        let agents = lock.agents();
322        assert_eq!(agents.len(), 1);
323        assert_eq!(agents[0].name, "waf");
324    }
325
326    #[test]
327    fn test_agent_names() {
328        let content = r#"
329[bundle]
330version = "26.01_1"
331
332[agents]
333waf = "0.2.0"
334ratelimit = "0.2.0"
335denylist = "0.2.0"
336
337[repositories]
338waf = "raskell-io/sentinel-agent-waf"
339ratelimit = "raskell-io/sentinel-agent-ratelimit"
340denylist = "raskell-io/sentinel-agent-denylist"
341"#;
342
343        let lock = BundleLock::from_str(content).unwrap();
344        let names = lock.agent_names();
345        assert_eq!(names.len(), 3);
346        assert!(names.contains(&"waf"));
347        assert!(names.contains(&"ratelimit"));
348        assert!(names.contains(&"denylist"));
349    }
350
351    #[test]
352    fn test_download_url_linux_amd64() {
353        let agent = AgentInfo {
354            name: "waf".to_string(),
355            version: "0.2.0".to_string(),
356            repository: "raskell-io/sentinel-agent-waf".to_string(),
357            binary_name: "sentinel-waf-agent".to_string(),
358        };
359
360        let url = agent.download_url("linux", "amd64");
361        assert_eq!(
362            url,
363            "https://github.com/raskell-io/sentinel-agent-waf/releases/download/v0.2.0/sentinel-waf-agent-0.2.0-linux-x86_64.tar.gz"
364        );
365    }
366
367    #[test]
368    fn test_download_url_linux_arm64() {
369        let agent = AgentInfo {
370            name: "ratelimit".to_string(),
371            version: "1.0.0".to_string(),
372            repository: "raskell-io/sentinel-agent-ratelimit".to_string(),
373            binary_name: "sentinel-ratelimit-agent".to_string(),
374        };
375
376        let url = agent.download_url("linux", "arm64");
377        assert_eq!(
378            url,
379            "https://github.com/raskell-io/sentinel-agent-ratelimit/releases/download/v1.0.0/sentinel-ratelimit-agent-1.0.0-linux-aarch64.tar.gz"
380        );
381    }
382
383    #[test]
384    fn test_download_url_darwin() {
385        let agent = AgentInfo {
386            name: "denylist".to_string(),
387            version: "0.5.0".to_string(),
388            repository: "raskell-io/sentinel-agent-denylist".to_string(),
389            binary_name: "sentinel-denylist-agent".to_string(),
390        };
391
392        let url = agent.download_url("darwin", "arm64");
393        assert!(url.contains("darwin"));
394        assert!(url.contains("aarch64"));
395    }
396
397    #[test]
398    fn test_checksum_url() {
399        let agent = AgentInfo {
400            name: "waf".to_string(),
401            version: "0.2.0".to_string(),
402            repository: "raskell-io/sentinel-agent-waf".to_string(),
403            binary_name: "sentinel-waf-agent".to_string(),
404        };
405
406        let url = agent.checksum_url("linux", "amd64");
407        assert!(url.ends_with(".sha256"));
408        assert!(url.contains("sentinel-waf-agent"));
409    }
410
411    #[test]
412    fn test_embedded_lock() {
413        // This test verifies the embedded lock file can be parsed
414        let lock = BundleLock::embedded().unwrap();
415        assert!(!lock.bundle.version.is_empty());
416        assert!(!lock.agents.is_empty());
417    }
418
419    #[test]
420    fn test_embedded_lock_has_required_agents() {
421        let lock = BundleLock::embedded().unwrap();
422
423        // Core agents
424        assert!(lock.agent("waf").is_some(), "waf agent should be in bundle");
425        assert!(
426            lock.agent("ratelimit").is_some(),
427            "ratelimit agent should be in bundle"
428        );
429        assert!(
430            lock.agent("denylist").is_some(),
431            "denylist agent should be in bundle"
432        );
433
434        // Security agents
435        assert!(
436            lock.agent("sentinelsec").is_some(),
437            "sentinelsec agent should be in bundle"
438        );
439        assert!(
440            lock.agent("ip-reputation").is_some(),
441            "ip-reputation agent should be in bundle"
442        );
443
444        // Scripting agents
445        assert!(lock.agent("lua").is_some(), "lua agent should be in bundle");
446        assert!(lock.agent("js").is_some(), "js agent should be in bundle");
447        assert!(
448            lock.agent("wasm").is_some(),
449            "wasm agent should be in bundle"
450        );
451
452        // Should have many agents total
453        assert!(
454            lock.agents.len() >= 20,
455            "bundle should have at least 20 agents"
456        );
457    }
458
459    #[test]
460    fn test_from_file_not_found() {
461        let result = BundleLock::from_file(Path::new("/nonexistent/path/lock.toml"));
462        assert!(matches!(result, Err(LockError::NotFound(_))));
463    }
464}