1use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9use thiserror::Error;
10
11#[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#[derive(Debug, Clone, Deserialize)]
29pub struct BundleLock {
30 pub bundle: BundleInfo,
32
33 pub agents: HashMap<String, String>,
35
36 pub repositories: HashMap<String, String>,
38
39 #[serde(default)]
41 pub checksums: HashMap<String, String>,
42}
43
44#[derive(Debug, Clone, Deserialize)]
46pub struct BundleInfo {
47 pub version: String,
49}
50
51#[derive(Debug, Clone)]
53pub struct AgentInfo {
54 pub name: String,
56
57 pub version: String,
59
60 pub repository: String,
62
63 pub binary_name: String,
65}
66
67impl BundleLock {
68 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 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 pub fn from_str(content: &str) -> Result<Self, LockError> {
85 let lock: BundleLock = toml::from_str(content)?;
86 Ok(lock)
87 }
88
89 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 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 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 pub fn agent_names(&self) -> Vec<&str> {
147 self.agents.keys().map(|s| s.as_str()).collect()
148 }
149}
150
151impl AgentInfo {
152 pub fn download_url(&self, os: &str, arch: &str) -> String {
158 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 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 assert!(lock.agent("orphan").is_none());
319 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 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 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 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 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 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}