Skip to main content

grapsus_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. Also supports fetching bundle
5//! metadata from the Grapsus API (`api.grapsusproxy.io`).
6
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::Path;
10use thiserror::Error;
11
12/// API endpoint for the Grapsus bundle registry.
13/// Trailing slash avoids a 308 redirect from Cloudflare Pages.
14const API_BUNDLE_URL: &str = "https://api.grapsusproxy.io/v1/bundle/";
15
16/// Legacy lock file URL (backward compatibility)
17const LEGACY_LOCK_URL: &str =
18    "https://raw.githubusercontent.com/grapsusproxy/grapsus/main/bundle-versions.lock";
19
20/// Maximum schema version this CLI understands
21const MAX_SCHEMA_VERSION: u32 = 1;
22
23/// Errors that can occur when parsing the lock file
24#[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// ---------------------------------------------------------------------------
45// API JSON response types
46// ---------------------------------------------------------------------------
47
48/// JSON response from `GET /v1/bundle/`
49#[derive(Debug, Deserialize)]
50pub struct ApiBundleResponse {
51    pub schema_version: u32,
52    pub bundle: ApiBundleMeta,
53    pub agents: HashMap<String, ApiBundleAgent>,
54}
55
56/// Bundle-level metadata from the API
57#[derive(Debug, Deserialize)]
58pub struct ApiBundleMeta {
59    pub version: String,
60    #[allow(dead_code)]
61    pub generated_at: String,
62}
63
64/// Per-agent data from the API bundle endpoint
65#[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            // Store precomputed download URLs keyed as "agent-os-arch"
89            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/// Bundle lock file structure
108#[derive(Debug, Clone, Deserialize)]
109pub struct BundleLock {
110    /// Bundle metadata
111    pub bundle: BundleInfo,
112
113    /// Agent versions (agent name -> version)
114    pub agents: HashMap<String, String>,
115
116    /// Agent repositories (agent name -> "owner/repo")
117    pub repositories: HashMap<String, String>,
118
119    /// Optional binary name overrides (agent name -> asset prefix)
120    /// When present, used instead of the default "grapsus-{name}-agent" pattern.
121    #[serde(default)]
122    pub binary_names: HashMap<String, String>,
123
124    /// Optional checksums for verification
125    #[serde(default)]
126    pub checksums: HashMap<String, String>,
127
128    /// Precomputed download URLs from the API (not in TOML, populated by API fetch).
129    /// Keys are "agent-platform" (e.g., "waf-linux-x86_64"), values are full URLs.
130    #[serde(skip)]
131    pub precomputed_urls: HashMap<String, String>,
132}
133
134/// Bundle metadata
135#[derive(Debug, Clone, Deserialize)]
136pub struct BundleInfo {
137    /// Bundle version (CalVer: YY.MM_PATCH)
138    pub version: String,
139}
140
141/// Information about a bundled agent
142#[derive(Debug, Clone)]
143pub struct AgentInfo {
144    /// Agent name (e.g., "waf", "ratelimit")
145    pub name: String,
146
147    /// Version string (e.g., "0.2.0")
148    pub version: String,
149
150    /// GitHub repository (e.g., "grapsusproxy/grapsus-agent-waf")
151    pub repository: String,
152
153    /// Binary name (e.g., "grapsus-waf-agent")
154    pub binary_name: String,
155
156    /// Precomputed download URLs from the API, keyed by platform (e.g., "linux-x86_64")
157    pub precomputed_urls: HashMap<String, String>,
158}
159
160impl BundleLock {
161    /// Load the embedded lock file (compiled into the binary)
162    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    /// Load lock file from a path
168    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    /// Parse lock file from string content
177    #[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    /// Fetch the latest bundle metadata, trying the API first with legacy fallback.
184    ///
185    /// Order:
186    /// 1. `GRAPSUS_API_URL` env var (if set) — for self-hosted registries
187    /// 2. `api.grapsusproxy.io/v1/bundle/` — primary API
188    /// 3. `raw.githubusercontent.com/.../bundle-versions.lock` — legacy fallback
189    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        // Determine API URL (env override or default)
197        let api_url =
198            std::env::var("GRAPSUS_API_URL").unwrap_or_else(|_| API_BUNDLE_URL.to_string());
199
200        // Try API endpoint first
201        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        // Fall back to legacy raw GitHub URL
213        Self::fetch_from_legacy(&client).await
214    }
215
216    /// Fetch bundle metadata from the JSON API
217    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        // Reject unknown schema versions
242        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    /// Fetch the legacy TOML lock file from raw.githubusercontent.com
253    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    /// Get information about all bundled agents
277    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    /// Get information about a specific agent
300    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    /// Extract precomputed URLs for a specific agent from the flat map
319    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    /// Get the list of agent names
331    pub fn agent_names(&self) -> Vec<&str> {
332        self.agents.keys().map(|s| s.as_str()).collect()
333    }
334}
335
336impl AgentInfo {
337    /// Get the download URL for this agent.
338    ///
339    /// Uses a precomputed URL from the API when available, otherwise constructs
340    /// the URL from the repository, version, and binary name.
341    ///
342    /// # Arguments
343    /// * `os` - Operating system (e.g., "linux", "darwin")
344    /// * `arch` - Architecture (e.g., "amd64", "arm64")
345    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        // Check for precomputed URL from API
353        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        // Fall back to constructed URL
359        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    /// Get the checksum URL for this agent
366    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        // orphan has no repository entry, so agent() should return None
512        assert!(lock.agent("orphan").is_none());
513        // agents() should skip orphan
514        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        // This test verifies the embedded lock file can be parsed
611        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        // Core agents
621        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        // Security agents
632        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        // Scripting agents
642        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        // Should have many agents total
650        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        // Precomputed URLs should be populated
704        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        // When no precomputed URL exists, should fall back to constructed URL
715        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        // Should use precomputed URL
747        let url = agent.download_url("linux", "amd64");
748        assert_eq!(url, "https://api.example.com/waf-custom.tar.gz");
749
750        // Should fall back for missing platform
751        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}