Skip to main content

ploit_malper/db/
pocketbase.rs

1use std::io::Write;
2use std::process::{Command, Stdio};
3
4use serde_json::{json, Value};
5
6use crate::config::DatabaseConfig;
7use crate::db::models::{Asset, Finding, Observation, ScanRun, Service};
8use crate::db::schema::{self, FieldDef};
9use crate::db::{collections, Storage};
10use crate::error::{AppError, Result};
11
12const REQUEST_TIMEOUT_S: u64 = 60;
13
14/// PocketBase storage backend. Talks to the PocketBase REST API over `curl`
15/// (matching the rest of PloitMalper's lightweight HTTP transport) and uses
16/// `pbctl` for declarative schema management when available.
17pub struct PocketBaseStorage {
18    config: DatabaseConfig,
19    token: String,
20    /// True once the current token has been verified against the API.
21    token_validated: bool,
22}
23
24struct HttpResponse {
25    status: u16,
26    body: Value,
27}
28
29fn curl_request(
30    method: &str,
31    url: &str,
32    token: Option<&str>,
33    body: Option<&Value>,
34    query: &[(&str, &str)],
35) -> Result<HttpResponse> {
36    let mut command = Command::new("curl");
37    command
38        .arg("-sS")
39        .arg("--max-time")
40        .arg(REQUEST_TIMEOUT_S.to_string())
41        .arg("-H")
42        .arg("Content-Type: application/json")
43        .arg("-w")
44        .arg("\n%{http_code}")
45        .stdout(Stdio::piped())
46        .stderr(Stdio::piped());
47
48    if let Some(token) = token {
49        command
50            .arg("-H")
51            .arg(format!("Authorization: Bearer {}", token));
52    }
53
54    if !query.is_empty() {
55        command.arg("-G");
56        for (key, value) in query {
57            command
58                .arg("--data-urlencode")
59                .arg(format!("{}={}", key, value));
60        }
61    }
62
63    if body.is_some() {
64        if method != "GET" {
65            command.arg("-X").arg(method);
66        }
67        command.arg("--data-binary").arg("@-").stdin(Stdio::piped());
68    } else if method != "GET" {
69        command.arg("-X").arg(method);
70    }
71
72    command.arg(url);
73
74    let mut child = command
75        .spawn()
76        .map_err(|e| AppError::Network(format!("failed to spawn curl: {e}")))?;
77
78    if let Some(b) = body {
79        if let Some(mut stdin) = child.stdin.take() {
80            let payload = serde_json::to_string(b)?;
81            stdin
82                .write_all(payload.as_bytes())
83                .map_err(|e| AppError::Network(format!("failed to write request body: {e}")))?;
84        }
85    }
86
87    let output = child
88        .wait_with_output()
89        .map_err(|e| AppError::Network(format!("failed to wait for curl: {e}")))?;
90
91    let raw = String::from_utf8_lossy(&output.stdout).to_string();
92    let trimmed = raw.trim_end();
93    let (body_str, status) = match trimmed.rsplit_once('\n') {
94        Some((rest, code)) => (rest, code.trim().parse::<u16>().unwrap_or(0)),
95        None => (trimmed, 0),
96    };
97
98    let body: Value = if body_str.trim().is_empty() {
99        Value::Null
100    } else {
101        serde_json::from_str(body_str).unwrap_or_else(|_| json!({ "raw": body_str }))
102    };
103
104    Ok(HttpResponse { status, body })
105}
106
107fn get_str(value: &Value, key: &str) -> String {
108    value
109        .get(key)
110        .and_then(Value::as_str)
111        .unwrap_or("")
112        .to_string()
113}
114
115fn get_opt_str(value: &Value, key: &str) -> Option<String> {
116    value
117        .get(key)
118        .and_then(Value::as_str)
119        .filter(|s| !s.is_empty())
120        .map(str::to_string)
121}
122
123fn get_vec(value: &Value, key: &str) -> Vec<String> {
124    value
125        .get(key)
126        .and_then(Value::as_array)
127        .map(|items| {
128            items
129                .iter()
130                .filter_map(|v| v.as_str().map(str::to_string))
131                .collect()
132        })
133        .unwrap_or_default()
134}
135
136fn get_vec_value(value: &Value, key: &str) -> Vec<Value> {
137    value
138        .get(key)
139        .and_then(Value::as_array)
140        .cloned()
141        .unwrap_or_default()
142}
143
144fn get_json(value: &Value, key: &str) -> Value {
145    value.get(key).cloned().unwrap_or(Value::Null)
146}
147
148fn as_json_object(value: &Value) -> Value {
149    match value {
150        Value::Null => Value::Object(Default::default()),
151        other => other.clone(),
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Record <-> model conversion
157// ---------------------------------------------------------------------------
158
159fn asset_to_record(asset: &Asset) -> Value {
160    let mut record = serde_json::Map::new();
161    record.insert("stable_id".into(), json!(asset.stable_id));
162    record.insert("asset_type".into(), json!(asset.asset_type));
163    record.insert("name".into(), json!(asset.name));
164    if let Some(v) = &asset.ip {
165        record.insert("ip".into(), json!(v));
166    }
167    if let Some(v) = &asset.fqdn {
168        record.insert("fqdn".into(), json!(v));
169    }
170    if let Some(v) = &asset.reverse_dns {
171        record.insert("reverse_dns".into(), json!(v));
172    }
173    record.insert("first_seen".into(), json!(asset.first_seen));
174    record.insert("last_seen".into(), json!(asset.last_seen));
175    record.insert("status".into(), json!(asset.status));
176    record.insert("metadata".into(), as_json_object(&asset.metadata));
177    Value::Object(record)
178}
179
180fn record_to_asset(value: &Value) -> Asset {
181    Asset {
182        stable_id: get_str(value, "stable_id"),
183        asset_type: get_str(value, "asset_type"),
184        name: get_str(value, "name"),
185        ip: get_opt_str(value, "ip"),
186        fqdn: get_opt_str(value, "fqdn"),
187        reverse_dns: get_opt_str(value, "reverse_dns"),
188        first_seen: get_str(value, "first_seen"),
189        last_seen: get_str(value, "last_seen"),
190        status: get_str(value, "status"),
191        metadata: get_json(value, "metadata"),
192    }
193}
194
195fn service_to_record(service: &Service) -> Value {
196    let mut record = serde_json::Map::new();
197    record.insert("stable_id".into(), json!(service.stable_id));
198    record.insert("asset_id".into(), json!(service.asset_id));
199    record.insert("port".into(), json!(service.port));
200    record.insert("protocol".into(), json!(service.protocol));
201    record.insert("service_name".into(), json!(service.service_name));
202    if let Some(v) = &service.product {
203        record.insert("product".into(), json!(v));
204    }
205    if let Some(v) = &service.version {
206        record.insert("version".into(), json!(v));
207    }
208    if let Some(v) = &service.version_str {
209        record.insert("version_str".into(), json!(v));
210    }
211    if let Some(v) = &service.banner {
212        record.insert("banner".into(), json!(v));
213    }
214    record.insert("technologies".into(), json!(service.technologies));
215    record.insert("cpes".into(), json!(service.cpes));
216    record.insert("first_seen".into(), json!(service.first_seen));
217    record.insert("last_seen".into(), json!(service.last_seen));
218    record.insert("status".into(), json!(service.status));
219    record.insert("metadata".into(), as_json_object(&service.metadata));
220    Value::Object(record)
221}
222
223fn record_to_service(value: &Value) -> Service {
224    Service {
225        stable_id: get_str(value, "stable_id"),
226        asset_id: get_str(value, "asset_id"),
227        port: value
228            .get("port")
229            .and_then(Value::as_u64)
230            .and_then(|p| u16::try_from(p).ok())
231            .unwrap_or(0),
232        protocol: get_str(value, "protocol"),
233        service_name: get_str(value, "service_name"),
234        product: get_opt_str(value, "product"),
235        version: get_opt_str(value, "version"),
236        version_str: get_opt_str(value, "version_str"),
237        banner: get_opt_str(value, "banner"),
238        technologies: get_vec(value, "technologies"),
239        cpes: get_vec(value, "cpes"),
240        first_seen: get_str(value, "first_seen"),
241        last_seen: get_str(value, "last_seen"),
242        status: get_str(value, "status"),
243        metadata: get_json(value, "metadata"),
244    }
245}
246
247fn finding_to_record(finding: &Finding) -> Value {
248    let mut record = serde_json::Map::new();
249    record.insert("stable_id".into(), json!(finding.stable_id));
250    record.insert("asset_id".into(), json!(finding.asset_id));
251    if let Some(v) = &finding.service_id {
252        record.insert("service_id".into(), json!(v));
253    }
254    record.insert("title".into(), json!(finding.title));
255    record.insert("severity".into(), json!(finding.severity));
256    record.insert("tool".into(), json!(finding.tool));
257    if let Some(v) = &finding.target_url {
258        record.insert("target_url".into(), json!(v));
259    }
260    if let Some(v) = &finding.detail {
261        record.insert("detail".into(), json!(v));
262    }
263    if let Some(v) = &finding.detail_path {
264        record.insert("detail_path".into(), json!(v));
265    }
266    if let Some(v) = &finding.reference {
267        record.insert("reference".into(), json!(v));
268    }
269    record.insert("cves".into(), json!(finding.cves));
270    record.insert("endpoints".into(), json!(finding.endpoints));
271    record.insert("technologies".into(), json!(finding.technologies));
272    record.insert("first_seen".into(), json!(finding.first_seen));
273    record.insert("last_seen".into(), json!(finding.last_seen));
274    record.insert("status".into(), json!(finding.status));
275    if let Some(v) = &finding.exploitability {
276        record.insert("exploitability".into(), json!(v));
277    }
278    record.insert("metadata".into(), as_json_object(&finding.metadata));
279    Value::Object(record)
280}
281
282fn record_to_finding(value: &Value) -> Finding {
283    Finding {
284        stable_id: get_str(value, "stable_id"),
285        asset_id: get_str(value, "asset_id"),
286        service_id: get_opt_str(value, "service_id"),
287        title: get_str(value, "title"),
288        severity: get_str(value, "severity"),
289        tool: get_str(value, "tool"),
290        target_url: get_opt_str(value, "target_url"),
291        detail: get_opt_str(value, "detail"),
292        detail_path: get_opt_str(value, "detail_path"),
293        reference: get_opt_str(value, "reference"),
294        cves: get_vec(value, "cves"),
295        endpoints: get_vec(value, "endpoints"),
296        technologies: get_vec(value, "technologies"),
297        first_seen: get_str(value, "first_seen"),
298        last_seen: get_str(value, "last_seen"),
299        status: get_str(value, "status"),
300        exploitability: get_opt_str(value, "exploitability"),
301        metadata: get_json(value, "metadata"),
302    }
303}
304
305fn observation_to_record(observation: &Observation) -> Value {
306    json!({
307        "stable_id": observation.stable_id,
308        "run_id": observation.run_id,
309        "subject_type": observation.subject_type,
310        "subject_id": observation.subject_id,
311        "kind": observation.kind,
312        "before": as_json_object(&observation.before),
313        "after": as_json_object(&observation.after),
314        "detail": observation.detail,
315        "observed_at": observation.observed_at,
316    })
317}
318
319fn record_to_observation(value: &Value) -> Observation {
320    Observation {
321        stable_id: get_str(value, "stable_id"),
322        run_id: get_str(value, "run_id"),
323        subject_type: get_str(value, "subject_type"),
324        subject_id: get_str(value, "subject_id"),
325        kind: get_str(value, "kind"),
326        before: get_json(value, "before"),
327        after: get_json(value, "after"),
328        detail: get_str(value, "detail"),
329        observed_at: get_str(value, "observed_at"),
330    }
331}
332
333fn scan_run_to_record(run: &ScanRun) -> Value {
334    json!({
335        "stable_id": run.stable_id,
336        "target": run.target,
337        "folder": run.folder,
338        "started_at": run.started_at,
339        "finished_at": run.finished_at,
340        "tools": run.tools,
341        "artifacts": run.artifacts,
342        "content_hash": run.content_hash,
343        "imported_at": run.imported_at,
344        "stats": as_json_object(&run.stats),
345    })
346}
347
348fn record_to_scan_run(value: &Value) -> ScanRun {
349    ScanRun {
350        stable_id: get_str(value, "stable_id"),
351        target: get_str(value, "target"),
352        folder: get_str(value, "folder"),
353        started_at: get_opt_str(value, "started_at"),
354        finished_at: get_opt_str(value, "finished_at"),
355        tools: get_vec(value, "tools"),
356        artifacts: get_vec_value(value, "artifacts"),
357        content_hash: get_str(value, "content_hash"),
358        imported_at: get_str(value, "imported_at"),
359        stats: get_json(value, "stats"),
360    }
361}
362
363impl PocketBaseStorage {
364    pub fn new(config: &DatabaseConfig) -> Result<Self> {
365        let mut url = config.pocketbase_url.trim().to_string();
366        if url.is_empty() {
367            url = "http://127.0.0.1:8090".to_string();
368        }
369        let url = url.trim_end_matches('/').to_string();
370        Ok(Self {
371            config: DatabaseConfig {
372                pocketbase_url: url,
373                ..config.clone()
374            },
375            token: config.pocketbase_token.clone(),
376            token_validated: false,
377        })
378    }
379
380    fn base_url(&self) -> &str {
381        &self.config.pocketbase_url
382    }
383
384    /// Current auth token, if a login has been performed.
385    pub fn token(&self) -> String {
386        self.token.clone()
387    }
388
389    /// Authenticate with the configured superuser credentials and cache a
390    /// fresh token. Returns the token.
391    pub fn login(&mut self) -> Result<String> {
392        let email = self.config.pocketbase_admin_email.trim();
393        let password = self.config.pocketbase_admin_password.trim();
394        if email.is_empty() || password.is_empty() {
395            return Err(AppError::Message(
396                "PocketBase requires superuser credentials (email + password) to connect. \
397                 Run `ploit-malper db_setup` to configure them."
398                    .to_string(),
399            ));
400        }
401        let url = format!(
402            "{}/api/collections/_superusers/auth-with-password",
403            self.base_url()
404        );
405        let body = json!({ "identity": email, "password": password });
406        let resp = curl_request("POST", &url, None, Some(&body), &[])?;
407        if resp.status != 200 && resp.status != 201 {
408            return Err(AppError::Network(format!(
409                "PocketBase authentication failed (HTTP {}): {}",
410                resp.status, resp.body
411            )));
412        }
413        let token = get_str(&resp.body, "token");
414        if token.is_empty() {
415            return Err(AppError::Network(format!(
416                "PocketBase authentication response did not include a token: {}",
417                resp.body
418            )));
419        }
420        self.token = token.clone();
421        self.config.pocketbase_token = token.clone();
422        self.token_validated = true;
423        Ok(token)
424    }
425
426    /// Ensure we have a usable token, verifying a cached one once and minting
427    /// a fresh token from credentials if it has expired.
428    fn ensure_token(&mut self) -> Result<String> {
429        if self.token_validated && !self.token.is_empty() {
430            return Ok(self.token.clone());
431        }
432        if !self.token.is_empty() {
433            let url = format!("{}/api/collections", self.base_url());
434            let resp = curl_request("GET", &url, Some(&self.token), None, &[("perPage", "1")])?;
435            if resp.status == 200 {
436                self.token_validated = true;
437                return Ok(self.token.clone());
438            }
439            self.token.clear();
440        }
441        self.login()
442    }
443
444    pub fn list_collection_names(&mut self) -> Result<Vec<String>> {
445        let token = self.ensure_token()?;
446        let url = format!("{}/api/collections", self.base_url());
447        let resp = curl_request("GET", &url, Some(&token), None, &[("perPage", "200")])?;
448        if resp.status != 200 {
449            return Err(AppError::Network(format!(
450                "Failed to list PocketBase collections (HTTP {}): {}",
451                resp.status, resp.body
452            )));
453        }
454        let names = resp
455            .body
456            .get("items")
457            .and_then(Value::as_array)
458            .map(|items| {
459                items
460                    .iter()
461                    .filter_map(|c| c.get("name").and_then(Value::as_str).map(str::to_string))
462                    .collect()
463            })
464            .unwrap_or_default();
465        Ok(names)
466    }
467
468    fn find_record(
469        &mut self,
470        collection: &str,
471        stable_id: &str,
472    ) -> Result<Option<(String, Value)>> {
473        let token = self.ensure_token()?;
474        let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
475        let filter = format!("stable_id = \"{}\"", stable_id);
476        let resp = curl_request(
477            "GET",
478            &url,
479            Some(&token),
480            None,
481            &[("filter", &filter), ("perPage", "500")],
482        )?;
483        if resp.status != 200 {
484            return Err(AppError::Network(format!(
485                "Failed to query records in '{}' (HTTP {}): {}",
486                collection, resp.status, resp.body
487            )));
488        }
489        let items = resp
490            .body
491            .get("items")
492            .and_then(Value::as_array)
493            .cloned()
494            .unwrap_or_default();
495        if let Some(first) = items.into_iter().next() {
496            let id = get_str(&first, "id");
497            return Ok(Some((id, first)));
498        }
499        Ok(None)
500    }
501
502    fn create_record(&mut self, collection: &str, record: &Value) -> Result<()> {
503        let token = self.ensure_token()?;
504        let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
505        let resp = curl_request("POST", &url, Some(&token), Some(record), &[])?;
506        if resp.status != 200 && resp.status != 201 {
507            return Err(AppError::Network(format!(
508                "Failed to create record in '{}' (HTTP {}): {}",
509                collection, resp.status, resp.body
510            )));
511        }
512        Ok(())
513    }
514
515    fn update_record(&mut self, collection: &str, record_id: &str, record: &Value) -> Result<()> {
516        let token = self.ensure_token()?;
517        let url = format!(
518            "{}/api/collections/{}/records/{}",
519            self.base_url(),
520            collection,
521            record_id
522        );
523        let resp = curl_request("PATCH", &url, Some(&token), Some(record), &[])?;
524        if resp.status != 200 && resp.status != 204 {
525            return Err(AppError::Network(format!(
526                "Failed to update record in '{}' (HTTP {}): {}",
527                collection, resp.status, resp.body
528            )));
529        }
530        Ok(())
531    }
532
533    fn upsert(&mut self, collection: &str, stable_id: &str, record: &Value) -> Result<()> {
534        if let Some((record_id, _)) = self.find_record(collection, stable_id)? {
535            self.update_record(collection, &record_id, record)
536        } else {
537            self.create_record(collection, record)
538        }
539    }
540
541    fn list_records(&mut self, collection: &str, filter: Option<&str>) -> Result<Vec<Value>> {
542        let token = self.ensure_token()?;
543        let url = format!("{}/api/collections/{}/records", self.base_url(), collection);
544        const PER_PAGE: u64 = 500;
545        let mut items: Vec<Value> = Vec::new();
546        let mut page: u64 = 1;
547        loop {
548            let page_str = page.to_string();
549            let per_str = PER_PAGE.to_string();
550            let mut query: Vec<(&str, &str)> =
551                vec![("page", page_str.as_str()), ("perPage", per_str.as_str())];
552            if let Some(f) = filter {
553                query.push(("filter", f));
554            }
555            let resp = curl_request("GET", &url, Some(&token), None, &query)?;
556            if resp.status != 200 {
557                return Err(AppError::Network(format!(
558                    "Failed to list records in '{}' (HTTP {}): {}",
559                    collection, resp.status, resp.body
560                )));
561            }
562            let batch = resp
563                .body
564                .get("items")
565                .and_then(Value::as_array)
566                .cloned()
567                .unwrap_or_default();
568            let count = batch.len();
569            items.extend(batch);
570            if count < PER_PAGE as usize {
571                break;
572            }
573            page += 1;
574        }
575        Ok(items)
576    }
577
578    /// Create a collection directly via the REST API (fallback when pbctl is
579    /// unavailable).
580    fn create_collection_via_api(&mut self, name: &str) -> Result<()> {
581        let token = self.ensure_token()?;
582        let url = format!("{}/api/collections", self.base_url());
583        let defs = schema::field_defs(name);
584        let fields: Vec<Value> = defs.iter().map(|def| self.field_to_json(def)).collect();
585        let body = json!({
586            "name": name,
587            "type": "base",
588            "fields": fields,
589        });
590        let resp = curl_request("POST", &url, Some(&token), Some(&body), &[])?;
591        if resp.status != 200 && resp.status != 201 {
592            return Err(AppError::Network(format!(
593                "Failed to create PocketBase collection '{}' (HTTP {}): {}",
594                name, resp.status, resp.body
595            )));
596        }
597        Ok(())
598    }
599
600    fn field_to_json(&self, def: &FieldDef) -> Value {
601        let mut field = serde_json::Map::new();
602        field.insert("name".to_string(), json!(def.name));
603        field.insert("type".to_string(), json!(def.kind));
604        field.insert("required".to_string(), json!(def.required));
605        field.insert("unique".to_string(), json!(def.unique));
606        field.insert("hidden".to_string(), json!(false));
607        if def.kind == "select" {
608            field.insert(
609                "values".to_string(),
610                json!(["ip", "hostname", "domain", "url"]),
611            );
612            field.insert("maxSelect".to_string(), json!(1));
613        }
614        Value::Object(field)
615    }
616}
617
618impl Storage for PocketBaseStorage {
619    fn kind(&self) -> &str {
620        "pocketbase"
621    }
622
623    fn ping(&mut self) -> Result<bool> {
624        let url = format!("{}/api/health", self.base_url());
625        let resp = curl_request("GET", &url, None, None, &[])?;
626        if resp.status == 200 {
627            return Ok(true);
628        }
629        // Health may be unauthenticated; fall back to an authenticated call.
630        match self.ensure_token() {
631            Ok(token) => {
632                let resp = curl_request("GET", &url, Some(&token), None, &[])?;
633                Ok(resp.status == 200)
634            }
635            Err(e) => Err(e),
636        }
637    }
638
639    fn ensure_schema(&mut self) -> Result<()> {
640        // Pure REST API path — no pbctl required. Create any required
641        // collections that are missing; leave existing ones untouched.
642        let existing = self.list_collection_names()?;
643        for name in collections::ALL {
644            if !existing.iter().any(|n| n == name) {
645                self.create_collection_via_api(name)?;
646            }
647        }
648        Ok(())
649    }
650
651    fn upsert_scan_run(&mut self, run: &ScanRun) -> Result<()> {
652        self.upsert(
653            collections::SCAN_RUNS,
654            &run.stable_id,
655            &scan_run_to_record(run),
656        )
657    }
658
659    fn get_scan_run(&mut self, run_id: &str) -> Result<Option<ScanRun>> {
660        if let Some((_, record)) = self.find_record(collections::SCAN_RUNS, run_id)? {
661            return Ok(Some(record_to_scan_run(&record)));
662        }
663        Ok(None)
664    }
665
666    fn list_scan_runs(&mut self) -> Result<Vec<ScanRun>> {
667        let records = self.list_records(collections::SCAN_RUNS, None)?;
668        Ok(records.iter().map(record_to_scan_run).collect())
669    }
670
671    fn upsert_asset(&mut self, asset: &Asset) -> Result<()> {
672        self.upsert(
673            collections::ASSETS,
674            &asset.stable_id,
675            &asset_to_record(asset),
676        )
677    }
678
679    fn get_asset(&mut self, asset_id: &str) -> Result<Option<Asset>> {
680        if let Some((_, record)) = self.find_record(collections::ASSETS, asset_id)? {
681            return Ok(Some(record_to_asset(&record)));
682        }
683        Ok(None)
684    }
685
686    fn list_assets(&mut self) -> Result<Vec<Asset>> {
687        let records = self.list_records(collections::ASSETS, None)?;
688        Ok(records.iter().map(record_to_asset).collect())
689    }
690
691    fn upsert_service(&mut self, service: &Service) -> Result<()> {
692        self.upsert(
693            collections::SERVICES,
694            &service.stable_id,
695            &service_to_record(service),
696        )
697    }
698
699    fn get_service(&mut self, service_id: &str) -> Result<Option<Service>> {
700        if let Some((_, record)) = self.find_record(collections::SERVICES, service_id)? {
701            return Ok(Some(record_to_service(&record)));
702        }
703        Ok(None)
704    }
705
706    fn list_services_for_asset(&mut self, asset_id: &str) -> Result<Vec<Service>> {
707        let filter = format!("asset_id = \"{}\"", asset_id);
708        let records = self.list_records(collections::SERVICES, Some(&filter))?;
709        Ok(records.iter().map(record_to_service).collect())
710    }
711
712    fn list_all_services(&mut self) -> Result<Vec<Service>> {
713        let records = self.list_records(collections::SERVICES, None)?;
714        Ok(records.iter().map(record_to_service).collect())
715    }
716
717    fn upsert_finding(&mut self, finding: &Finding) -> Result<()> {
718        self.upsert(
719            collections::FINDINGS,
720            &finding.stable_id,
721            &finding_to_record(finding),
722        )
723    }
724
725    fn get_finding(&mut self, finding_id: &str) -> Result<Option<Finding>> {
726        if let Some((_, record)) = self.find_record(collections::FINDINGS, finding_id)? {
727            return Ok(Some(record_to_finding(&record)));
728        }
729        Ok(None)
730    }
731
732    fn list_findings_for_asset(&mut self, asset_id: &str) -> Result<Vec<Finding>> {
733        let filter = format!("asset_id = \"{}\"", asset_id);
734        let records = self.list_records(collections::FINDINGS, Some(&filter))?;
735        Ok(records.iter().map(record_to_finding).collect())
736    }
737
738    fn list_all_findings(&mut self) -> Result<Vec<Finding>> {
739        let records = self.list_records(collections::FINDINGS, None)?;
740        Ok(records.iter().map(record_to_finding).collect())
741    }
742
743    fn add_observation(&mut self, observation: &Observation) -> Result<()> {
744        if self.get_observation(&observation.stable_id)?.is_some() {
745            return Ok(());
746        }
747        self.create_record(
748            collections::OBSERVATIONS,
749            &observation_to_record(observation),
750        )
751    }
752
753    fn get_observation(&mut self, obs_id: &str) -> Result<Option<Observation>> {
754        if let Some((_, record)) = self.find_record(collections::OBSERVATIONS, obs_id)? {
755            return Ok(Some(record_to_observation(&record)));
756        }
757        Ok(None)
758    }
759
760    fn list_observations_for(
761        &mut self,
762        subject_type: &str,
763        subject_id: &str,
764    ) -> Result<Vec<Observation>> {
765        let filter = format!(
766            "subject_type = \"{}\" && subject_id = \"{}\"",
767            subject_type, subject_id
768        );
769        let records = self.list_records(collections::OBSERVATIONS, Some(&filter))?;
770        Ok(records.iter().map(record_to_observation).collect())
771    }
772
773    fn list_all_observations(&mut self) -> Result<Vec<Observation>> {
774        let records = self.list_records(collections::OBSERVATIONS, None)?;
775        Ok(records.iter().map(record_to_observation).collect())
776    }
777}