WP-20/21: contract and failing tests for vulnerability management
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
17
backend/crates/infrastructure/migrations/0004_findings.sql
Normal file
17
backend/crates/infrastructure/migrations/0004_findings.sql
Normal file
@ -0,0 +1,17 @@
|
||||
CREATE TABLE findings (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('os', 'image')),
|
||||
target TEXT NOT NULL,
|
||||
cve_id TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
package TEXT NOT NULL,
|
||||
installed_version TEXT NOT NULL,
|
||||
fixed_version TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open', 'acknowledged', 'fixed')),
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX findings_target_status ON findings(target, status);
|
||||
CREATE INDEX findings_status_severity ON findings(status, severity);
|
||||
@ -7,6 +7,7 @@ pub mod mail;
|
||||
pub mod password;
|
||||
pub mod sqlite;
|
||||
pub mod token;
|
||||
pub mod trivy;
|
||||
|
||||
pub use cipher::AesGcmCipher;
|
||||
pub use db::{connect, DbPool};
|
||||
@ -16,6 +17,7 @@ pub use host::{
|
||||
pub use k8s::{FakeClusterGateway, KubeGateway};
|
||||
pub use mail::LettreMailer;
|
||||
pub use password::Argon2Hasher;
|
||||
pub use sqlite::SqliteInventory;
|
||||
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
||||
pub use sqlite::{SqliteFindings, SqliteInventory};
|
||||
pub use token::JwtIssuer;
|
||||
pub use trivy::{FakeScanner, TrivyScanner};
|
||||
|
||||
@ -485,3 +485,262 @@ impl domain::ports::InventoryRepository for SqliteInventory {
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
use domain::vuln::{
|
||||
Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind,
|
||||
};
|
||||
|
||||
pub struct SqliteFindings(pub DbPool);
|
||||
|
||||
fn finding_from_row(r: &SqliteRow) -> Finding {
|
||||
Finding {
|
||||
id: r.get("id"),
|
||||
target_kind: TargetKind::parse(r.get::<String, _>("target_kind").as_str())
|
||||
.unwrap_or(TargetKind::Os),
|
||||
target: r.get("target"),
|
||||
raw: RawFinding {
|
||||
cve_id: r.get("cve_id"),
|
||||
severity: Severity::parse(r.get::<String, _>("severity").as_str()),
|
||||
package: r.get("package"),
|
||||
installed_version: r.get("installed_version"),
|
||||
fixed_version: r.get("fixed_version"),
|
||||
title: r.get("title"),
|
||||
url: r.get("url"),
|
||||
},
|
||||
status: FindingStatus::parse(r.get::<String, _>("status").as_str())
|
||||
.unwrap_or(FindingStatus::Open),
|
||||
first_seen: parse_ts(r.get::<String, _>("first_seen").as_str()),
|
||||
last_seen: parse_ts(r.get::<String, _>("last_seen").as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, status, first_seen, last_seen";
|
||||
|
||||
/// Severity ordering for SQL: higher is worse.
|
||||
fn severity_rank(s: Severity) -> i32 {
|
||||
match s {
|
||||
Severity::Critical => 4,
|
||||
Severity::High => 3,
|
||||
Severity::Medium => 2,
|
||||
Severity::Low => 1,
|
||||
Severity::Unknown => 0,
|
||||
}
|
||||
}
|
||||
|
||||
const SEVERITY_RANK_SQL: &str = "CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END";
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FindingRepository for SqliteFindings {
|
||||
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"SELECT {FINDING_COLS} FROM findings WHERE target = ? AND status != 'fixed'"
|
||||
))
|
||||
.bind(target)
|
||||
.fetch_all(&self.0)
|
||||
.await
|
||||
.map(|rows| rows.iter().map(finding_from_row).collect())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn insert(&self, f: &Finding) -> Result<(), DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
))
|
||||
.bind(f.id)
|
||||
.bind(f.target_kind.as_str())
|
||||
.bind(&f.target)
|
||||
.bind(&f.raw.cve_id)
|
||||
.bind(f.raw.severity.as_str())
|
||||
.bind(&f.raw.package)
|
||||
.bind(&f.raw.installed_version)
|
||||
.bind(&f.raw.fixed_version)
|
||||
.bind(&f.raw.title)
|
||||
.bind(&f.raw.url)
|
||||
.bind(f.status.as_str())
|
||||
.bind(f.first_seen.to_rfc3339())
|
||||
.bind(f.last_seen.to_rfc3339())
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn touch(&self, ids: &[Uuid], last_seen: DateTime<Utc>) -> Result<(), DomainError> {
|
||||
for id in ids {
|
||||
sqlx::query("UPDATE findings SET last_seen = ? WHERE id = ?")
|
||||
.bind(last_seen.to_rfc3339())
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map_err(storage)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
||||
let res = sqlx::query("UPDATE findings SET status = ? WHERE id = ?")
|
||||
.bind(status.as_str())
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map_err(storage)?;
|
||||
(res.rows_affected() > 0)
|
||||
.then_some(())
|
||||
.ok_or(DomainError::NotFound)
|
||||
}
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Finding>, DomainError> {
|
||||
sqlx::query(&format!("SELECT {FINDING_COLS} FROM findings WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(finding_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||
let mut sql = format!("SELECT {FINDING_COLS} FROM findings WHERE 1=1");
|
||||
if !filter.include_fixed {
|
||||
sql.push_str(" AND status != 'fixed'");
|
||||
}
|
||||
if filter.status.is_some() {
|
||||
sql.push_str(" AND status = ?");
|
||||
}
|
||||
if filter.target.is_some() {
|
||||
sql.push_str(" AND target = ?");
|
||||
}
|
||||
if filter.min_severity.is_some() {
|
||||
sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?"));
|
||||
}
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY {SEVERITY_RANK_SQL} DESC, target, cve_id"
|
||||
));
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(s) = filter.status {
|
||||
q = q.bind(s.as_str());
|
||||
}
|
||||
if let Some(t) = &filter.target {
|
||||
q = q.bind(t);
|
||||
}
|
||||
if let Some(m) = filter.min_severity {
|
||||
q = q.bind(severity_rank(m));
|
||||
}
|
||||
q.fetch_all(&self.0)
|
||||
.await
|
||||
.map(|rows| rows.iter().map(finding_from_row).collect())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError> {
|
||||
let sql = match kind {
|
||||
Some(_) => "SELECT severity, COUNT(*) FROM findings WHERE status != 'fixed' AND target_kind = ? GROUP BY severity",
|
||||
None => "SELECT severity, COUNT(*) FROM findings WHERE status != 'fixed' AND ? = ? GROUP BY severity",
|
||||
};
|
||||
let rows: Vec<(String, i64)> = match kind {
|
||||
Some(k) => {
|
||||
sqlx::query_as(sql)
|
||||
.bind(k.as_str())
|
||||
.fetch_all(&self.0)
|
||||
.await
|
||||
}
|
||||
None => sqlx::query_as(sql).bind(1).bind(1).fetch_all(&self.0).await,
|
||||
}
|
||||
.map_err(storage)?;
|
||||
let mut c = SeverityCounts::default();
|
||||
for (sev, n) in rows {
|
||||
for _ in 0..n {
|
||||
c.add(Severity::parse(&sev));
|
||||
}
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod finding_tests {
|
||||
use super::*;
|
||||
use domain::ports::FindingRepository;
|
||||
|
||||
fn finding(target: &str, kind: TargetKind, cve: &str, sev: Severity) -> Finding {
|
||||
Finding {
|
||||
id: Uuid::new_v4(),
|
||||
target_kind: kind,
|
||||
target: target.into(),
|
||||
raw: RawFinding {
|
||||
cve_id: cve.into(),
|
||||
severity: sev,
|
||||
package: "p".into(),
|
||||
installed_version: "1".into(),
|
||||
fixed_version: None,
|
||||
title: "t".into(),
|
||||
url: "u".into(),
|
||||
},
|
||||
status: FindingStatus::Open,
|
||||
first_seen: Utc::now(),
|
||||
last_seen: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_list_filter_counts_and_status() {
|
||||
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||
let repo = SqliteFindings(pool);
|
||||
let a = finding("os", TargetKind::Os, "CVE-A", Severity::Critical);
|
||||
let b = finding("os", TargetKind::Os, "CVE-B", Severity::Low);
|
||||
let c = finding("img:1", TargetKind::Image, "CVE-C", Severity::High);
|
||||
for f in [&a, &b, &c] {
|
||||
repo.insert(f).await.unwrap();
|
||||
}
|
||||
assert_eq!(repo.active_by_target("os").await.unwrap().len(), 2);
|
||||
let high = repo
|
||||
.list(&FindingFilter {
|
||||
min_severity: Some(Severity::High),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
high.iter()
|
||||
.map(|f| f.raw.cve_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["CVE-A", "CVE-C"]
|
||||
);
|
||||
assert_eq!(
|
||||
repo.list(&FindingFilter {
|
||||
target: Some("img:1".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
repo.set_status(b.id, FindingStatus::Fixed).await.unwrap();
|
||||
assert_eq!(repo.active_by_target("os").await.unwrap().len(), 1);
|
||||
assert_eq!(repo.list(&FindingFilter::default()).await.unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
repo.list(&FindingFilter {
|
||||
include_fixed: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
repo.counts(None).await.unwrap(),
|
||||
SeverityCounts {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(repo.counts(Some(TargetKind::Os)).await.unwrap().critical, 1);
|
||||
|
||||
let later = Utc::now() + chrono::Duration::hours(1);
|
||||
repo.touch(&[a.id], later).await.unwrap();
|
||||
assert!(repo.get(a.id).await.unwrap().unwrap().last_seen > a.last_seen);
|
||||
assert_eq!(
|
||||
repo.set_status(Uuid::new_v4(), FindingStatus::Open)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
DomainError::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
224
backend/crates/infrastructure/src/trivy.rs
Normal file
224
backend/crates/infrastructure/src/trivy.rs
Normal file
@ -0,0 +1,224 @@
|
||||
//! Trivy CLI scanner (JSON output) and a fake for development.
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{LineSink, VulnerabilityScanner};
|
||||
use domain::vuln::{RawFinding, Severity};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::host::CommandRunner;
|
||||
|
||||
pub struct TrivyScanner {
|
||||
runner: Arc<dyn CommandRunner>,
|
||||
}
|
||||
|
||||
impl TrivyScanner {
|
||||
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||
Self { runner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `trivy ... --format json` output into findings (all results merged, deduplicated).
|
||||
pub fn parse_trivy_json(_json: &str) -> Result<Vec<RawFinding>, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VulnerabilityScanner for TrivyScanner {
|
||||
async fn version(&self) -> Result<String, DomainError> {
|
||||
let _ = &self.runner;
|
||||
todo!()
|
||||
}
|
||||
async fn scan_os(&self, _out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
async fn scan_image(
|
||||
&self,
|
||||
_image: &str,
|
||||
_out: &dyn LineSink,
|
||||
) -> Result<Vec<RawFinding>, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample findings for development (FAKE_HOST=true).
|
||||
pub struct FakeScanner;
|
||||
|
||||
#[async_trait]
|
||||
impl VulnerabilityScanner for FakeScanner {
|
||||
async fn version(&self) -> Result<String, DomainError> {
|
||||
Ok("fake-trivy 0.0".into())
|
||||
}
|
||||
async fn scan_os(&self, out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError> {
|
||||
out.line("fake: scanning rootfs");
|
||||
Ok(vec![
|
||||
RawFinding {
|
||||
cve_id: "CVE-2024-5535".into(),
|
||||
severity: Severity::High,
|
||||
package: "openssl".into(),
|
||||
installed_version: "3.0.15-1~deb12u1".into(),
|
||||
fixed_version: Some("3.0.16-1~deb12u1".into()),
|
||||
title: "openssl: SSL_select_next_proto buffer overread".into(),
|
||||
url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(),
|
||||
},
|
||||
RawFinding {
|
||||
cve_id: "CVE-2023-45853".into(),
|
||||
severity: Severity::Critical,
|
||||
package: "zlib1g".into(),
|
||||
installed_version: "1:1.2.13.dfsg-1".into(),
|
||||
fixed_version: None,
|
||||
title: "zlib: integer overflow in zipOpenNewFileInZip4_64".into(),
|
||||
url: "https://avd.aquasec.com/nvd/cve-2023-45853".into(),
|
||||
},
|
||||
RawFinding {
|
||||
cve_id: "CVE-2011-3374".into(),
|
||||
severity: Severity::Low,
|
||||
package: "apt".into(),
|
||||
installed_version: "2.6.1".into(),
|
||||
fixed_version: None,
|
||||
title: "apt: unsigned repository".into(),
|
||||
url: "https://avd.aquasec.com/nvd/cve-2011-3374".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
async fn scan_image(
|
||||
&self,
|
||||
image: &str,
|
||||
out: &dyn LineSink,
|
||||
) -> Result<Vec<RawFinding>, DomainError> {
|
||||
out.line(&format!("fake: scanning image {image}"));
|
||||
Ok(if image.contains("gitea") {
|
||||
vec![RawFinding {
|
||||
cve_id: "CVE-2024-24790".into(),
|
||||
severity: Severity::Critical,
|
||||
package: "stdlib".into(),
|
||||
installed_version: "1.21.5".into(),
|
||||
fixed_version: Some("1.21.11".into()),
|
||||
title: "golang: net/netip unexpected behavior".into(),
|
||||
url: "https://avd.aquasec.com/nvd/cve-2024-24790".into(),
|
||||
}]
|
||||
} else if image.contains("postgres") {
|
||||
vec![RawFinding {
|
||||
cve_id: "CVE-2024-4741".into(),
|
||||
severity: Severity::Medium,
|
||||
package: "libssl3".into(),
|
||||
installed_version: "3.0.13".into(),
|
||||
fixed_version: Some("3.0.14".into()),
|
||||
title: "openssl: use after free".into(),
|
||||
url: "https://avd.aquasec.com/nvd/cve-2024-4741".into(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"{
|
||||
"SchemaVersion": 2,
|
||||
"Results": [
|
||||
{"Target": "debian 12", "Class": "os-pkgs", "Type": "debian",
|
||||
"Vulnerabilities": [
|
||||
{"VulnerabilityID": "CVE-2024-5535", "PkgName": "openssl", "InstalledVersion": "3.0.15-1~deb12u1", "FixedVersion": "3.0.16-1~deb12u1",
|
||||
"Severity": "HIGH", "Title": "openssl: buffer overread", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5535"},
|
||||
{"VulnerabilityID": "CVE-2024-5535", "PkgName": "libssl3", "InstalledVersion": "3.0.15-1~deb12u1", "FixedVersion": "3.0.16-1~deb12u1",
|
||||
"Severity": "HIGH", "Title": "openssl: buffer overread", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5535"},
|
||||
{"VulnerabilityID": "CVE-2011-3374", "PkgName": "apt", "InstalledVersion": "2.6.1", "Severity": "LOW", "PrimaryURL": "https://x"},
|
||||
{"VulnerabilityID": "CVE-2011-3374", "PkgName": "apt", "InstalledVersion": "2.6.1", "Severity": "LOW", "PrimaryURL": "https://x"}
|
||||
]},
|
||||
{"Target": "Node.js", "Class": "lang-pkgs", "Type": "node-pkg"},
|
||||
{"Target": "usr/bin/x", "Class": "lang-pkgs", "Type": "gobinary",
|
||||
"Vulnerabilities": [{"VulnerabilityID": "GHSA-1", "PkgName": "stdlib", "InstalledVersion": "1.21.5", "Severity": "WEIRD"}]}
|
||||
]}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_results_merges_targets_and_dedups() {
|
||||
let f = parse_trivy_json(SAMPLE).unwrap();
|
||||
assert_eq!(f.len(), 4, "{f:?}");
|
||||
let ssl = f
|
||||
.iter()
|
||||
.find(|x| x.cve_id == "CVE-2024-5535" && x.package == "openssl")
|
||||
.unwrap();
|
||||
assert_eq!(ssl.severity, Severity::High);
|
||||
assert_eq!(ssl.fixed_version.as_deref(), Some("3.0.16-1~deb12u1"));
|
||||
assert_eq!(ssl.title, "openssl: buffer overread");
|
||||
let apt = f.iter().filter(|x| x.cve_id == "CVE-2011-3374").count();
|
||||
assert_eq!(apt, 1, "duplicates removed");
|
||||
let ghsa = f.iter().find(|x| x.cve_id == "GHSA-1").unwrap();
|
||||
assert_eq!(ghsa.severity, Severity::Unknown);
|
||||
assert_eq!(ghsa.fixed_version, None);
|
||||
assert!(ghsa.title.contains("GHSA-1"), "title falls back to id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_invalid_json() {
|
||||
assert_eq!(parse_trivy_json(r#"{"Results": null}"#).unwrap(), vec![]);
|
||||
assert_eq!(parse_trivy_json(r#"{}"#).unwrap(), vec![]);
|
||||
assert!(matches!(
|
||||
parse_trivy_json("nope").unwrap_err(),
|
||||
DomainError::Unavailable(_)
|
||||
));
|
||||
}
|
||||
|
||||
struct Canned;
|
||||
#[async_trait]
|
||||
impl CommandRunner for Canned {
|
||||
async fn run(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
) -> Result<crate::host::Output, DomainError> {
|
||||
assert_eq!(program, "trivy");
|
||||
Ok(match args[0] {
|
||||
"--version" => crate::host::Output {
|
||||
stdout: "Version: 0.58.1\n".into(),
|
||||
success: true,
|
||||
..Default::default()
|
||||
},
|
||||
"rootfs" | "image" => {
|
||||
assert!(args.contains(&"--format") && args.contains(&"json"));
|
||||
crate::host::Output {
|
||||
stdout: SAMPLE.into(),
|
||||
success: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
_ => crate::host::Output {
|
||||
success: false,
|
||||
stderr: "bad".into(),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn run_streaming(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &[&str],
|
||||
_: &dyn LineSink,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct Sink;
|
||||
impl LineSink for Sink {
|
||||
fn line(&self, _: &str) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_invokes_trivy_and_parses() {
|
||||
let s = TrivyScanner::new(Arc::new(Canned));
|
||||
assert_eq!(s.version().await.unwrap(), "0.58.1");
|
||||
assert_eq!(s.scan_os(&Sink).await.unwrap().len(), 4);
|
||||
assert_eq!(
|
||||
s.scan_image("gitea/gitea:1.22", &Sink).await.unwrap().len(),
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user