Skip to main content

observer_core/
providers.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use crate::config::{ObserverConfig, ProviderConfig};
5use crate::error::{ObserverError, ObserverResult};
6use crate::inventory::{Inventory, InventoryEntry, InventoryRunner};
7use crate::report::TelemetryEntry;
8use base64::engine::general_purpose::STANDARD;
9use base64::Engine;
10use serde::Deserialize;
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResolvedProvider {
17    pub command: PathBuf,
18    pub args: Vec<String>,
19    pub cwd: PathBuf,
20    pub inherit_env: bool,
21    pub env: BTreeMap<String, String>,
22}
23
24pub type ResolvedProviders = BTreeMap<String, ResolvedProvider>;
25
26#[derive(Debug, Clone, Deserialize)]
27pub struct ProviderRunResponse {
28    pub provider: String,
29    pub target: String,
30    pub exit: i32,
31    pub out_b64: String,
32    pub err_b64: String,
33    #[serde(default)]
34    pub telemetry: Vec<TelemetryEntry>,
35}
36
37#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
38pub struct ProviderListResponse {
39    pub provider: String,
40    pub tests: Vec<ProviderListEntry>,
41}
42
43#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
44pub struct ProviderListEntry {
45    pub name: String,
46    pub target: String,
47}
48
49pub fn resolve_providers(
50    config: &ObserverConfig,
51    config_path: &Path,
52) -> ObserverResult<ResolvedProviders> {
53    let config_path = if config_path.is_absolute() {
54        config_path.to_path_buf()
55    } else {
56        std::env::current_dir()
57            .map_err(|error| ObserverError::Config(error.to_string()))?
58            .join(config_path)
59    };
60
61    let base_dir = config_path.parent().ok_or_else(|| {
62        ObserverError::Config("observer.toml must have a parent directory".to_owned())
63    })?;
64
65    let mut resolved = BTreeMap::new();
66    for (provider_id, provider) in &config.providers {
67        resolved.insert(
68            provider_id.clone(),
69            resolve_provider_entry(provider, base_dir)?,
70        );
71    }
72    Ok(resolved)
73}
74
75pub fn run_provider_target(
76    resolved: &ResolvedProvider,
77    target: &str,
78    timeout_ms: u32,
79) -> ObserverResult<ProviderRunResponse> {
80    let mut command = Command::new(&resolved.command);
81    command.args(&resolved.args);
82    command.arg("run");
83    command.arg("--target");
84    command.arg(target);
85    command.arg("--timeout-ms");
86    command.arg(timeout_ms.to_string());
87    command.current_dir(&resolved.cwd);
88
89    if !resolved.inherit_env {
90        command.env_clear();
91    }
92    command.envs(&resolved.env);
93
94    let output = command
95        .output()
96        .map_err(|error| ObserverError::Runtime(format!("provider host spawn failed: {error}")))?;
97
98    if !output.status.success() {
99        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
100        if stderr == "timeout" {
101            return Err(ObserverError::Runtime("timeout".to_owned()));
102        }
103        let msg = if stderr.is_empty() {
104            "provider host failed".to_owned()
105        } else {
106            format!("provider host failed: {stderr}")
107        };
108        return Err(ObserverError::Runtime(msg));
109    }
110
111    serde_json::from_slice::<ProviderRunResponse>(&output.stdout)
112        .map_err(|error| ObserverError::Runtime(format!("invalid provider run response: {error}")))
113}
114
115pub fn list_provider_tests(resolved: &ResolvedProvider) -> ObserverResult<ProviderListResponse> {
116    let mut command = Command::new(&resolved.command);
117    command.args(&resolved.args);
118    command.arg("list");
119    command.current_dir(&resolved.cwd);
120
121    if !resolved.inherit_env {
122        command.env_clear();
123    }
124    command.envs(&resolved.env);
125
126    let output = command
127        .output()
128        .map_err(|error| ObserverError::Runtime(format!("provider host spawn failed: {error}")))?;
129
130    if !output.status.success() {
131        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
132        let msg = if stderr.is_empty() {
133            "provider host failed".to_owned()
134        } else {
135            format!("provider host failed: {stderr}")
136        };
137        return Err(ObserverError::Runtime(msg));
138    }
139
140    let response = serde_json::from_slice::<ProviderListResponse>(&output.stdout)
141        .map_err(|error| ObserverError::Runtime(format!("invalid provider list response: {error}")))?;
142
143    let mut previous_name = None::<&str>;
144    for test in &response.tests {
145        if test.name.trim().is_empty() {
146            return Err(ObserverError::Runtime(
147                "provider list returned empty canonical test name".to_owned(),
148            ));
149        }
150        if test.target.trim().is_empty() {
151            return Err(ObserverError::Runtime(
152                "provider list returned empty target".to_owned(),
153            ));
154        }
155        if let Some(previous) = previous_name {
156            if previous.as_bytes().cmp(test.name.as_bytes()).is_gt() {
157                return Err(ObserverError::Runtime(
158                    "provider list response is not in canonical name order".to_owned(),
159                ));
160            }
161        }
162        previous_name = Some(test.name.as_str());
163    }
164
165    Ok(response)
166}
167
168pub fn provider_list_to_inventory(response: &ProviderListResponse) -> ObserverResult<Inventory> {
169    let mut entries = Vec::with_capacity(response.tests.len());
170    let mut seen_names = BTreeMap::new();
171    for test in &response.tests {
172        if seen_names.insert(test.name.clone(), ()).is_some() {
173            return Err(ObserverError::Runtime(format!(
174                "provider list returned duplicate canonical test name `{}`",
175                test.name
176            )));
177        }
178        entries.push(InventoryEntry {
179            name: test.name.clone(),
180            runner: InventoryRunner::Provider {
181                provider: response.provider.clone(),
182                target: test.target.clone(),
183            },
184        });
185    }
186    Ok(Inventory { entries })
187}
188
189pub fn decode_b64_bytes(value: &str) -> ObserverResult<Vec<u8>> {
190    STANDARD
191        .decode(value)
192        .map_err(|error| ObserverError::Runtime(format!("invalid base64 payload: {error}")))
193}
194
195fn resolve_provider_entry(provider: &ProviderConfig, base_dir: &Path) -> ObserverResult<ResolvedProvider> {
196    if provider.command.trim().is_empty() {
197        return Err(ObserverError::Config(
198            "provider command must not be empty".to_owned(),
199        ));
200    }
201
202    let command = resolve_path(&provider.command, base_dir);
203    let cwd = provider
204        .cwd
205        .as_deref()
206        .map(|cwd| resolve_path(cwd, base_dir))
207        .unwrap_or_else(|| base_dir.to_path_buf());
208
209    Ok(ResolvedProvider {
210        command,
211        args: provider.args.clone(),
212        cwd,
213        inherit_env: provider.inherit_env,
214        env: provider.env.clone(),
215    })
216}
217
218fn resolve_path(value: &str, base_dir: &Path) -> PathBuf {
219    let path = PathBuf::from(value);
220    if path.is_absolute() {
221        path
222    } else {
223        base_dir.join(path)
224    }
225}