1#![allow(clippy::redundant_closure_call)]
8
9use std::path::Path;
10use std::sync::Arc;
11use std::time::Duration;
12
13use crate::host::{HostError, Incoming, LogSink, PluginProcess};
14use serde_json::{Map, json};
15
16use crate::msg::{DeliveryFile, Request, Response};
17use crate::{MAX_VERSION, MIN_VERSION, PluginId, parse_executable_name, parse_package_executable_name};
18
19const TIMEOUT: Duration = Duration::from_secs(20);
20
21#[derive(Debug)]
22pub struct Check {
23 pub plugin: String,
25 pub name: &'static str,
26 pub passed: bool,
27 pub detail: String,
28}
29
30fn quiet() -> LogSink {
31 Arc::new(|_, _| {})
32}
33
34fn start_versions(
35 path: &Path,
36 plugin: Option<&PluginId>,
37 env: &[(&str, &str)],
38 versions: (u32, u32),
39) -> Result<PluginProcess, HostError> {
40 let mut p = PluginProcess::spawn_env(path, quiet(), env)?;
41 if plugin.is_some() {
42 p.ask_for(plugin.cloned());
43 }
44 p.handshake(versions, TIMEOUT)?;
45 Ok(p)
46}
47
48pub fn run(path: &Path) -> Vec<Check> {
50 run_with_env(path, &[])
51}
52
53pub fn run_with_env(path: &Path, env: &[(&str, &str)]) -> Vec<Check> {
56 let file = path.file_name().unwrap().to_string_lossy().to_string();
57 if let Some((kind, name)) = parse_executable_name(&file) {
58 return run_plugin(path, &PluginId::new(kind, name), false, env);
59 }
60 let fail = |detail: String| {
61 vec![Check {
62 plugin: file.clone(),
63 name: "the executable is named dre-plugin-<package> or dre-<kind>-<name>",
64 passed: false,
65 detail,
66 }]
67 };
68 let Some(package) = parse_package_executable_name(&file) else {
69 return fail(format!("`{file}` is neither"));
70 };
71 let provides = match start_versions(path, None, env, (MIN_VERSION, MAX_VERSION)) {
72 Ok(p) => {
73 let provides = p.info().provides.clone();
74 let _ = p.close();
75 provides
76 }
77 Err(e) => return fail(format!("the handshake failed: {e}")),
78 };
79 let mut out = Vec::new();
80 let unknown = PluginId::new(crate::Kind::Format, "dre_conformance_unknown");
81 let refused = match start_versions(path, Some(&unknown), env, (MIN_VERSION, MAX_VERSION)) {
82 Err(HostError::Plugin { .. }) => Ok(()),
83 Err(e) => Err(format!("expected an error reply, got: {e}")),
84 Ok(_) => Err(format!("the package served {unknown}, which it doesn't provide")),
85 };
86 out.push(Check {
87 plugin: package,
88 name: "asking for a plugin the package doesn't provide gets an error reply",
89 passed: refused.is_ok(),
90 detail: refused.err().unwrap_or_default(),
91 });
92 for id in &provides {
93 out.extend(run_plugin(path, id, true, env));
94 }
95 out
96}
97
98fn run_plugin(path: &Path, id: &PluginId, ask: bool, env: &[(&str, &str)]) -> Vec<Check> {
100 let asked = ask.then_some(id);
101 let start = |path: &Path| start_versions(path, asked, env, (MIN_VERSION, MAX_VERSION));
102 let mut out = Vec::new();
103 let mut check = |name: &'static str, r: Result<(), String>| {
104 out.push(Check {
105 plugin: id.to_string(),
106 name,
107 passed: r.is_ok(),
108 detail: r.err().unwrap_or_default(),
109 });
110 };
111
112 check(
113 "handshake negotiates a supported version and reports its identity",
114 (|| {
115 let p = start(path).map_err(|e| e.to_string())?;
116 let info = p.info().clone();
117 if info.kind != id.kind || info.name != id.name {
118 return Err(format!(
119 "expected {id}, handshake says {}/{}",
120 info.kind, info.name
121 ));
122 }
123 if !info.provides.contains(id) {
124 return Err(format!("`provides` doesn't list {id}: {:?}", info.provides));
125 }
126 if info.version.is_empty() {
127 return Err("empty plugin version".into());
128 }
129 p.close().map_err(|e| e.to_string())
130 })(),
131 );
132
133 check(
134 "an unsupported protocol range is refused, not hung on",
135 (|| {
136 let far = MAX_VERSION + 1000;
137 match start_versions(path, asked, env, (far, far)) {
138 Err(HostError::Incompatible { plugin_range, .. }) if plugin_range.1 < far => Ok(()),
139 Err(e) => Err(format!("expected a version mismatch, got: {e}")),
140 Ok(_) => Err("the plugin accepted a protocol version it can't speak".into()),
141 }
142 })(),
143 );
144
145 check(
146 "describe is answered",
147 (|| {
148 let mut p = start(path).map_err(|e| e.to_string())?;
149 p.describe().map_err(|e| e.to_string())?;
150 p.close().map_err(|e| e.to_string())
151 })(),
152 );
153
154 check(
155 "validate is advertised, answered, and refuses an option the plugin doesn't declare",
156 (|| {
157 let mut p = start(path).map_err(|e| e.to_string())?;
158 if !p.has(crate::CAP_VALIDATE) {
159 return Err("the plugin doesn't advertise `validate`".into());
160 }
161 let (_, fields) = p.describe_all().map_err(|e| e.to_string())?;
162 p.validate(Map::new()).map_err(|e| e.to_string())?;
163 let key = "dre_conformance_unknown_option";
164 if fields.iter().any(|f| f.name == key) {
165 return Err(format!("the plugin declares `{key}`"));
166 }
167 let errors = p
168 .validate(json!({key: true}).as_object().unwrap().clone())
169 .map_err(|e| e.to_string())?;
170 if !errors.iter().any(|e| e.contains(key)) {
171 return Err(format!("an unknown option was accepted: {errors:?}"));
172 }
173 p.close().map_err(|e| e.to_string())
174 })(),
175 );
176
177 check(
178 "an unknown request gets an error reply and the plugin keeps serving",
179 (|| {
180 let mut p = start(path).map_err(|e| e.to_string())?;
181 p.write_raw(&frame_json(r#"{"type":"frobnicate"}"#))
182 .map_err(|e| e.to_string())?;
183 match p
184 .recv(Some(TIMEOUT), "an error reply")
185 .map_err(|e| e.to_string())?
186 {
187 Incoming::Json(Response::Error { .. }) => {}
188 other => return Err(format!("expected an error reply, got {other:?}")),
189 }
190 p.describe()
191 .map_err(|e| format!("plugin stopped serving after an unknown request: {e}"))?;
192 p.close().map_err(|e| e.to_string())
193 })(),
194 );
195
196 check(
197 "a request meant for another plugin kind gets an error reply",
198 (|| {
199 let mut p = start(path).map_err(|e| e.to_string())?;
200 let req = match p.info().kind {
201 crate::Kind::Source => Request::Finish {},
202 _ => Request::Check {
203 sql: "select 1".into(),
204 },
205 };
206 p.send(&req).map_err(|e| e.to_string())?;
207 match p
208 .recv(Some(TIMEOUT), "an error reply")
209 .map_err(|e| e.to_string())?
210 {
211 Incoming::Json(Response::Error { .. }) => {}
212 other => return Err(format!("expected an error reply, got {other:?}")),
213 }
214 p.close().map_err(|e| e.to_string())
215 })(),
216 );
217
218 check(
219 "a malformed frame is reported or ends the plugin, never hangs",
220 (|| {
221 let mut p = start(path).map_err(|e| e.to_string())?;
222 let _ = p.write_raw(&[0, 0, 0, 2, b'X', b'!']);
223 match p.recv(Some(TIMEOUT), "a reply to a malformed frame") {
224 Ok(Incoming::Json(Response::Error { .. })) | Err(HostError::Crashed { .. }) => Ok(()),
225 Err(HostError::Timeout { .. }) => Err("the plugin hung on a malformed frame".into()),
226 other => Err(format!("unexpected reply to a malformed frame: {other:?}")),
227 }
228 })(),
229 );
230
231 let destination = start(path).ok().map(|p| {
234 let info = p.info().clone();
235 let _ = p.close();
236 info
237 });
238 if let Some(info) = destination.filter(|i| i.kind == crate::Kind::Destination) {
239 let missing = |n: &str| DeliveryFile {
240 local_path: format!("/nonexistent/dre-conformance/{n}"),
241 remote_path: Some(format!("dre-conformance/{n}")),
242 };
243 let one = missing("a.csv");
244 let mut forms = vec![(
245 "deliver with options gets a reply and the plugin keeps serving",
246 Request::Deliver {
247 local_path: Some(one.local_path),
248 remote_path: one.remote_path,
249 files: Vec::new(),
250 connection: Map::new(),
251 options: json!({"conformance": true}).as_object().unwrap().clone(),
252 },
253 )];
254 if info.capabilities.iter().any(|c| c == crate::CAP_MULTI_FILE) {
255 forms.push((
256 "a multi-file deliver gets a reply and the plugin keeps serving",
257 Request::Deliver {
258 local_path: None,
259 remote_path: None,
260 files: vec![missing("a.csv"), missing("b.csv")],
261 connection: Map::new(),
262 options: Map::new(),
263 },
264 ));
265 }
266 for (name, req) in forms {
267 check(
268 name,
269 (|| {
270 let mut p = start(path).map_err(|e| e.to_string())?;
271 p.send(&req).map_err(|e| e.to_string())?;
272 match p
273 .recv(Some(TIMEOUT), "a deliver reply")
274 .map_err(|e| e.to_string())?
275 {
276 Incoming::Json(Response::Delivered { .. } | Response::Error { .. }) => {}
277 other => return Err(format!("expected delivered or error, got {other:?}")),
278 }
279 p.describe()
280 .map_err(|e| format!("plugin stopped serving after a deliver: {e}"))?;
281 p.close().map_err(|e| e.to_string())
282 })(),
283 );
284 }
285 }
286
287 check(
288 "close ends the process with exit code 0",
289 (|| {
290 let mut p = start(path).map_err(|e| e.to_string())?;
291 p.send(&Request::Close {}).map_err(|e| e.to_string())?;
292 let _ = p.recv(Some(TIMEOUT), "the close reply");
293 match p.wait_exit(TIMEOUT) {
294 Some(s) if s.success() => Ok(()),
295 Some(s) => Err(format!("exited with {s}")),
296 None => Err("still running after close".into()),
297 }
298 })(),
299 );
300
301 check(
302 "end of input ends the process",
303 (|| {
304 let mut p = start(path).map_err(|e| e.to_string())?;
305 match p.wait_exit(TIMEOUT) {
306 Some(_) => Ok(()),
307 None => Err("still running after stdin closed".into()),
308 }
309 })(),
310 );
311
312 out
313}
314
315pub fn assert_conforms(path: &Path) {
317 let checks = run(path);
318 let failed: Vec<String> = checks
319 .iter()
320 .filter(|c| !c.passed)
321 .map(|c| format!(" ✗ {}: {}: {}", c.plugin, c.name, c.detail))
322 .collect();
323 assert!(
324 failed.is_empty(),
325 "{} fails protocol conformance:\n{}",
326 path.display(),
327 failed.join("\n")
328 );
329}
330
331fn frame_json(body: &str) -> Vec<u8> {
332 let mut v = Vec::new();
333 v.extend_from_slice(&((body.len() + 1) as u32).to_be_bytes());
334 v.push(crate::frame::JSON_TAG);
335 v.extend_from_slice(body.as_bytes());
336 v
337}