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 #[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 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 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 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 pub fn agent_names(&self) -> Vec<&str> {
148 self.agents.keys().map(|s| s.as_str()).collect()
149 }
150}
151
152impl AgentInfo {
153 pub fn download_url(&self, os: &str, arch: &str) -> String {
159 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 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 assert!(lock.agent("orphan").is_none());
320 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 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 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 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 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 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}