1use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::Path;
10use thiserror::Error;
11
12const API_BUNDLE_URL: &str = "https://api.grapsusproxy.io/v1/bundle/";
15
16const LEGACY_LOCK_URL: &str =
18 "https://raw.githubusercontent.com/grapsusproxy/grapsus/main/bundle-versions.lock";
19
20const MAX_SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, Error)]
25pub enum LockError {
26 #[error("Failed to read lock file: {0}")]
27 Io(#[from] std::io::Error),
28
29 #[error("Failed to parse lock file: {0}")]
30 Parse(#[from] toml::de::Error),
31
32 #[error("Lock file not found at: {0}")]
33 NotFound(String),
34
35 #[error("Failed to fetch lock file from remote: {0}")]
36 Fetch(String),
37
38 #[error(
39 "Unsupported API schema version {version} (max supported: {max}). Please update grapsus."
40 )]
41 UnsupportedSchema { version: u32, max: u32 },
42}
43
44#[derive(Debug, Deserialize)]
50pub struct ApiBundleResponse {
51 pub schema_version: u32,
52 pub bundle: ApiBundleMeta,
53 pub agents: HashMap<String, ApiBundleAgent>,
54}
55
56#[derive(Debug, Deserialize)]
58pub struct ApiBundleMeta {
59 pub version: String,
60 #[allow(dead_code)]
61 pub generated_at: String,
62}
63
64#[derive(Debug, Deserialize)]
66pub struct ApiBundleAgent {
67 pub version: String,
68 pub repository: String,
69 pub binary_name: String,
70 #[serde(default)]
71 pub download_urls: HashMap<String, String>,
72 #[serde(default)]
73 pub checksums: HashMap<String, String>,
74}
75
76impl From<ApiBundleResponse> for BundleLock {
77 fn from(api: ApiBundleResponse) -> Self {
78 let mut agents = HashMap::new();
79 let mut repositories = HashMap::new();
80 let mut binary_names = HashMap::new();
81 let mut download_urls = HashMap::new();
82
83 for (name, agent) in &api.agents {
84 agents.insert(name.clone(), agent.version.clone());
85 repositories.insert(name.clone(), agent.repository.clone());
86 binary_names.insert(name.clone(), agent.binary_name.clone());
87
88 for (platform, url) in &agent.download_urls {
90 download_urls.insert(format!("{}-{}", name, platform), url.clone());
91 }
92 }
93
94 BundleLock {
95 bundle: BundleInfo {
96 version: api.bundle.version,
97 },
98 agents,
99 repositories,
100 binary_names,
101 checksums: HashMap::new(),
102 precomputed_urls: download_urls,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Deserialize)]
109pub struct BundleLock {
110 pub bundle: BundleInfo,
112
113 pub agents: HashMap<String, String>,
115
116 pub repositories: HashMap<String, String>,
118
119 #[serde(default)]
122 pub binary_names: HashMap<String, String>,
123
124 #[serde(default)]
126 pub checksums: HashMap<String, String>,
127
128 #[serde(skip)]
131 pub precomputed_urls: HashMap<String, String>,
132}
133
134#[derive(Debug, Clone, Deserialize)]
136pub struct BundleInfo {
137 pub version: String,
139}
140
141#[derive(Debug, Clone)]
143pub struct AgentInfo {
144 pub name: String,
146
147 pub version: String,
149
150 pub repository: String,
152
153 pub binary_name: String,
155
156 pub precomputed_urls: HashMap<String, String>,
158}
159
160impl BundleLock {
161 pub fn embedded() -> Result<Self, LockError> {
163 let content = include_str!(concat!(env!("OUT_DIR"), "/bundle-versions.lock"));
164 Self::from_str(content)
165 }
166
167 pub fn from_file(path: &Path) -> Result<Self, LockError> {
169 if !path.exists() {
170 return Err(LockError::NotFound(path.display().to_string()));
171 }
172 let content = std::fs::read_to_string(path)?;
173 Self::from_str(&content)
174 }
175
176 #[allow(clippy::should_implement_trait)]
178 pub fn from_str(content: &str) -> Result<Self, LockError> {
179 let lock: BundleLock = toml::from_str(content)?;
180 Ok(lock)
181 }
182
183 pub async fn fetch_latest() -> Result<Self, LockError> {
190 let client = reqwest::Client::builder()
191 .user_agent("grapsus-bundle")
192 .timeout(std::time::Duration::from_secs(15))
193 .build()
194 .map_err(|e| LockError::Fetch(e.to_string()))?;
195
196 let api_url =
198 std::env::var("GRAPSUS_API_URL").unwrap_or_else(|_| API_BUNDLE_URL.to_string());
199
200 match Self::fetch_from_api(&client, &api_url).await {
202 Ok(lock) => return Ok(lock),
203 Err(e) => {
204 tracing::debug!(
205 error = %e,
206 url = %api_url,
207 "API fetch failed, falling back to legacy lock file"
208 );
209 }
210 }
211
212 Self::fetch_from_legacy(&client).await
214 }
215
216 async fn fetch_from_api(client: &reqwest::Client, url: &str) -> Result<Self, LockError> {
218 let response = client
219 .get(url)
220 .header("Accept", "application/json")
221 .send()
222 .await
223 .map_err(|e| LockError::Fetch(e.to_string()))?;
224
225 if !response.status().is_success() {
226 return Err(LockError::Fetch(format!(
227 "HTTP {} from {}",
228 response.status(),
229 url
230 )));
231 }
232
233 let body = response
234 .text()
235 .await
236 .map_err(|e| LockError::Fetch(e.to_string()))?;
237
238 let api_response: ApiBundleResponse = serde_json::from_str(&body)
239 .map_err(|e| LockError::Fetch(format!("Invalid API response: {}", e)))?;
240
241 if api_response.schema_version > MAX_SCHEMA_VERSION {
243 return Err(LockError::UnsupportedSchema {
244 version: api_response.schema_version,
245 max: MAX_SCHEMA_VERSION,
246 });
247 }
248
249 Ok(BundleLock::from(api_response))
250 }
251
252 async fn fetch_from_legacy(client: &reqwest::Client) -> Result<Self, LockError> {
254 let response = client
255 .get(LEGACY_LOCK_URL)
256 .send()
257 .await
258 .map_err(|e| LockError::Fetch(e.to_string()))?;
259
260 if !response.status().is_success() {
261 return Err(LockError::Fetch(format!(
262 "HTTP {} from {}",
263 response.status(),
264 LEGACY_LOCK_URL
265 )));
266 }
267
268 let content = response
269 .text()
270 .await
271 .map_err(|e| LockError::Fetch(e.to_string()))?;
272
273 Self::from_str(&content)
274 }
275
276 pub fn agents(&self) -> Vec<AgentInfo> {
278 self.agents
279 .iter()
280 .filter_map(|(name, version)| {
281 let repository = self.repositories.get(name)?;
282 let binary_name = self
283 .binary_names
284 .get(name)
285 .cloned()
286 .unwrap_or_else(|| format!("grapsus-{}-agent", name));
287 let precomputed_urls = self.precomputed_urls_for(name);
288 Some(AgentInfo {
289 name: name.clone(),
290 version: version.clone(),
291 repository: repository.clone(),
292 binary_name,
293 precomputed_urls,
294 })
295 })
296 .collect()
297 }
298
299 pub fn agent(&self, name: &str) -> Option<AgentInfo> {
301 let version = self.agents.get(name)?;
302 let repository = self.repositories.get(name)?;
303 let binary_name = self
304 .binary_names
305 .get(name)
306 .cloned()
307 .unwrap_or_else(|| format!("grapsus-{}-agent", name));
308 let precomputed_urls = self.precomputed_urls_for(name);
309 Some(AgentInfo {
310 name: name.to_string(),
311 version: version.clone(),
312 repository: repository.clone(),
313 binary_name,
314 precomputed_urls,
315 })
316 }
317
318 fn precomputed_urls_for(&self, agent_name: &str) -> HashMap<String, String> {
320 let prefix = format!("{}-", agent_name);
321 self.precomputed_urls
322 .iter()
323 .filter_map(|(key, url)| {
324 key.strip_prefix(&prefix)
325 .map(|platform| (platform.to_string(), url.clone()))
326 })
327 .collect()
328 }
329
330 pub fn agent_names(&self) -> Vec<&str> {
332 self.agents.keys().map(|s| s.as_str()).collect()
333 }
334}
335
336impl AgentInfo {
337 pub fn download_url(&self, os: &str, arch: &str) -> String {
346 let release_arch = match arch {
347 "amd64" => "x86_64",
348 "arm64" => "aarch64",
349 _ => arch,
350 };
351
352 let platform_key = format!("{}-{}", os, release_arch);
354 if let Some(url) = self.precomputed_urls.get(&platform_key) {
355 return url.clone();
356 }
357
358 format!(
360 "https://github.com/{}/releases/download/v{}/{}-{}-{}-{}.tar.gz",
361 self.repository, self.version, self.binary_name, self.version, os, release_arch
362 )
363 }
364
365 pub fn checksum_url(&self, os: &str, arch: &str) -> String {
367 format!("{}.sha256", self.download_url(os, arch))
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_parse_lock_file() {
377 let content = r#"
378[bundle]
379version = "26.01_1"
380
381[agents]
382waf = "0.2.0"
383ratelimit = "0.2.0"
384
385[repositories]
386waf = "grapsusproxy/grapsus-agent-waf"
387ratelimit = "grapsusproxy/grapsus-agent-ratelimit"
388"#;
389
390 let lock = BundleLock::from_str(content).unwrap();
391 assert_eq!(lock.bundle.version, "26.01_1");
392 assert_eq!(lock.agents.get("waf"), Some(&"0.2.0".to_string()));
393 assert_eq!(lock.agents.get("ratelimit"), Some(&"0.2.0".to_string()));
394 }
395
396 #[test]
397 fn test_parse_lock_file_with_checksums() {
398 let content = r#"
399[bundle]
400version = "26.01_2"
401
402[agents]
403waf = "0.3.0"
404
405[repositories]
406waf = "grapsusproxy/grapsus-agent-waf"
407
408[checksums]
409waf = "abc123def456"
410"#;
411
412 let lock = BundleLock::from_str(content).unwrap();
413 assert_eq!(lock.checksums.get("waf"), Some(&"abc123def456".to_string()));
414 }
415
416 #[test]
417 fn test_parse_lock_file_empty_checksums() {
418 let content = r#"
419[bundle]
420version = "26.01_1"
421
422[agents]
423waf = "0.2.0"
424
425[repositories]
426waf = "grapsusproxy/grapsus-agent-waf"
427"#;
428
429 let lock = BundleLock::from_str(content).unwrap();
430 assert!(lock.checksums.is_empty());
431 }
432
433 #[test]
434 fn test_parse_invalid_toml() {
435 let content = "this is not valid toml {{{";
436 let result = BundleLock::from_str(content);
437 assert!(result.is_err());
438 }
439
440 #[test]
441 fn test_parse_missing_bundle_section() {
442 let content = r#"
443[agents]
444waf = "0.2.0"
445
446[repositories]
447waf = "grapsusproxy/grapsus-agent-waf"
448"#;
449 let result = BundleLock::from_str(content);
450 assert!(result.is_err());
451 }
452
453 #[test]
454 fn test_agent_info() {
455 let content = r#"
456[bundle]
457version = "26.01_1"
458
459[agents]
460waf = "0.2.0"
461
462[repositories]
463waf = "grapsusproxy/grapsus-agent-waf"
464"#;
465
466 let lock = BundleLock::from_str(content).unwrap();
467 let agent = lock.agent("waf").unwrap();
468
469 assert_eq!(agent.name, "waf");
470 assert_eq!(agent.version, "0.2.0");
471 assert_eq!(agent.binary_name, "grapsus-waf-agent");
472
473 let url = agent.download_url("linux", "amd64");
474 assert!(url.contains("grapsus-waf-agent"));
475 assert!(url.contains("v0.2.0"));
476 assert!(url.contains("x86_64"));
477 }
478
479 #[test]
480 fn test_agent_not_found() {
481 let content = r#"
482[bundle]
483version = "26.01_1"
484
485[agents]
486waf = "0.2.0"
487
488[repositories]
489waf = "grapsusproxy/grapsus-agent-waf"
490"#;
491
492 let lock = BundleLock::from_str(content).unwrap();
493 assert!(lock.agent("nonexistent").is_none());
494 }
495
496 #[test]
497 fn test_agent_without_repository() {
498 let content = r#"
499[bundle]
500version = "26.01_1"
501
502[agents]
503waf = "0.2.0"
504orphan = "1.0.0"
505
506[repositories]
507waf = "grapsusproxy/grapsus-agent-waf"
508"#;
509
510 let lock = BundleLock::from_str(content).unwrap();
511 assert!(lock.agent("orphan").is_none());
513 let agents = lock.agents();
515 assert_eq!(agents.len(), 1);
516 assert_eq!(agents[0].name, "waf");
517 }
518
519 #[test]
520 fn test_agent_names() {
521 let content = r#"
522[bundle]
523version = "26.01_1"
524
525[agents]
526waf = "0.2.0"
527ratelimit = "0.2.0"
528denylist = "0.2.0"
529
530[repositories]
531waf = "grapsusproxy/grapsus-agent-waf"
532ratelimit = "grapsusproxy/grapsus-agent-ratelimit"
533denylist = "grapsusproxy/grapsus-agent-denylist"
534"#;
535
536 let lock = BundleLock::from_str(content).unwrap();
537 let names = lock.agent_names();
538 assert_eq!(names.len(), 3);
539 assert!(names.contains(&"waf"));
540 assert!(names.contains(&"ratelimit"));
541 assert!(names.contains(&"denylist"));
542 }
543
544 #[test]
545 fn test_download_url_linux_amd64() {
546 let agent = AgentInfo {
547 name: "waf".to_string(),
548 version: "0.2.0".to_string(),
549 repository: "grapsusproxy/grapsus-agent-waf".to_string(),
550 binary_name: "grapsus-waf-agent".to_string(),
551 precomputed_urls: HashMap::new(),
552 };
553
554 let url = agent.download_url("linux", "amd64");
555 assert_eq!(
556 url,
557 "https://github.com/grapsusproxy/grapsus-agent-waf/releases/download/v0.2.0/grapsus-waf-agent-0.2.0-linux-x86_64.tar.gz"
558 );
559 }
560
561 #[test]
562 fn test_download_url_linux_arm64() {
563 let agent = AgentInfo {
564 name: "ratelimit".to_string(),
565 version: "1.0.0".to_string(),
566 repository: "grapsusproxy/grapsus-agent-ratelimit".to_string(),
567 binary_name: "grapsus-ratelimit-agent".to_string(),
568 precomputed_urls: HashMap::new(),
569 };
570
571 let url = agent.download_url("linux", "arm64");
572 assert_eq!(
573 url,
574 "https://github.com/grapsusproxy/grapsus-agent-ratelimit/releases/download/v1.0.0/grapsus-ratelimit-agent-1.0.0-linux-aarch64.tar.gz"
575 );
576 }
577
578 #[test]
579 fn test_download_url_darwin() {
580 let agent = AgentInfo {
581 name: "denylist".to_string(),
582 version: "0.5.0".to_string(),
583 repository: "grapsusproxy/grapsus-agent-denylist".to_string(),
584 binary_name: "grapsus-denylist-agent".to_string(),
585 precomputed_urls: HashMap::new(),
586 };
587
588 let url = agent.download_url("darwin", "arm64");
589 assert!(url.contains("darwin"));
590 assert!(url.contains("aarch64"));
591 }
592
593 #[test]
594 fn test_checksum_url() {
595 let agent = AgentInfo {
596 name: "waf".to_string(),
597 version: "0.2.0".to_string(),
598 repository: "grapsusproxy/grapsus-agent-waf".to_string(),
599 binary_name: "grapsus-waf-agent".to_string(),
600 precomputed_urls: HashMap::new(),
601 };
602
603 let url = agent.checksum_url("linux", "amd64");
604 assert!(url.ends_with(".sha256"));
605 assert!(url.contains("grapsus-waf-agent"));
606 }
607
608 #[test]
609 fn test_embedded_lock() {
610 let lock = BundleLock::embedded().unwrap();
612 assert!(!lock.bundle.version.is_empty());
613 assert!(!lock.agents.is_empty());
614 }
615
616 #[test]
617 fn test_embedded_lock_has_required_agents() {
618 let lock = BundleLock::embedded().unwrap();
619
620 assert!(lock.agent("waf").is_some(), "waf agent should be in bundle");
622 assert!(
623 lock.agent("ratelimit").is_some(),
624 "ratelimit agent should be in bundle"
625 );
626 assert!(
627 lock.agent("denylist").is_some(),
628 "denylist agent should be in bundle"
629 );
630
631 assert!(
633 lock.agent("grapsussec").is_some(),
634 "grapsussec agent should be in bundle"
635 );
636 assert!(
637 lock.agent("ip-reputation").is_some(),
638 "ip-reputation agent should be in bundle"
639 );
640
641 assert!(lock.agent("lua").is_some(), "lua agent should be in bundle");
643 assert!(lock.agent("js").is_some(), "js agent should be in bundle");
644 assert!(
645 lock.agent("wasm").is_some(),
646 "wasm agent should be in bundle"
647 );
648
649 assert!(
651 lock.agents.len() >= 20,
652 "bundle should have at least 20 agents"
653 );
654 }
655
656 #[test]
657 fn test_from_file_not_found() {
658 let result = BundleLock::from_file(Path::new("/nonexistent/path/lock.toml"));
659 assert!(matches!(result, Err(LockError::NotFound(_))));
660 }
661
662 #[test]
663 fn test_api_bundle_response_conversion() {
664 let mut agents = HashMap::new();
665 let mut download_urls = HashMap::new();
666 download_urls.insert(
667 "linux-x86_64".to_string(),
668 "https://example.com/waf-linux-x86_64.tar.gz".to_string(),
669 );
670 download_urls.insert(
671 "darwin-aarch64".to_string(),
672 "https://example.com/waf-darwin-aarch64.tar.gz".to_string(),
673 );
674
675 agents.insert(
676 "waf".to_string(),
677 ApiBundleAgent {
678 version: "0.3.0".to_string(),
679 repository: "grapsusproxy/grapsus-agent-waf".to_string(),
680 binary_name: "grapsus-waf-agent".to_string(),
681 download_urls,
682 checksums: HashMap::new(),
683 },
684 );
685
686 let api = ApiBundleResponse {
687 schema_version: 1,
688 bundle: ApiBundleMeta {
689 version: "26.02_13".to_string(),
690 generated_at: "2026-02-23T00:00:00Z".to_string(),
691 },
692 agents,
693 };
694
695 let lock = BundleLock::from(api);
696 assert_eq!(lock.bundle.version, "26.02_13");
697 assert_eq!(lock.agents.get("waf"), Some(&"0.3.0".to_string()));
698 assert_eq!(
699 lock.binary_names.get("waf"),
700 Some(&"grapsus-waf-agent".to_string())
701 );
702
703 let agent = lock.agent("waf").unwrap();
705 let url = agent.download_url("linux", "amd64");
706 assert_eq!(url, "https://example.com/waf-linux-x86_64.tar.gz");
707
708 let url = agent.download_url("darwin", "arm64");
709 assert_eq!(url, "https://example.com/waf-darwin-aarch64.tar.gz");
710 }
711
712 #[test]
713 fn test_precomputed_url_fallback() {
714 let agent = AgentInfo {
716 name: "waf".to_string(),
717 version: "0.3.0".to_string(),
718 repository: "grapsusproxy/grapsus-agent-waf".to_string(),
719 binary_name: "grapsus-waf-agent".to_string(),
720 precomputed_urls: HashMap::new(),
721 };
722
723 let url = agent.download_url("linux", "amd64");
724 assert_eq!(
725 url,
726 "https://github.com/grapsusproxy/grapsus-agent-waf/releases/download/v0.3.0/grapsus-waf-agent-0.3.0-linux-x86_64.tar.gz"
727 );
728 }
729
730 #[test]
731 fn test_precomputed_url_used_when_available() {
732 let mut precomputed = HashMap::new();
733 precomputed.insert(
734 "linux-x86_64".to_string(),
735 "https://api.example.com/waf-custom.tar.gz".to_string(),
736 );
737
738 let agent = AgentInfo {
739 name: "waf".to_string(),
740 version: "0.3.0".to_string(),
741 repository: "grapsusproxy/grapsus-agent-waf".to_string(),
742 binary_name: "grapsus-waf-agent".to_string(),
743 precomputed_urls: precomputed,
744 };
745
746 let url = agent.download_url("linux", "amd64");
748 assert_eq!(url, "https://api.example.com/waf-custom.tar.gz");
749
750 let url = agent.download_url("darwin", "arm64");
752 assert!(url.contains("github.com"));
753 }
754
755 #[test]
756 fn test_unsupported_schema_version_error() {
757 let err = LockError::UnsupportedSchema {
758 version: 99,
759 max: 1,
760 };
761 let msg = err.to_string();
762 assert!(msg.contains("99"));
763 assert!(msg.contains("update grapsus"));
764 }
765}