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 "protocol 0 is still spoken, for an older core",
147 (|| {
148 let p = start_versions(path, asked, env, (0, 0)).map_err(|e| e.to_string())?;
149 match p.info().protocol_version {
150 0 => p.close().map_err(|e| e.to_string()),
151 v => Err(format!("offered only version 0, the plugin chose {v}")),
152 }
153 })(),
154 );
155
156 check(
157 "protocol 1 is spoken",
158 (|| {
159 let p = start(path).map_err(|e| e.to_string())?;
160 match p.info().protocol_version {
161 v if v >= 1 => p.close().map_err(|e| e.to_string()),
162 v => Err(format!(
163 "offered versions up to {MAX_VERSION}, the plugin chose {v}"
164 )),
165 }
166 })(),
167 );
168
169 check(
170 "replies carry the request's id, and a cancel for a request that isn't running is ignored",
171 (|| {
172 let mut p = start(path).map_err(|e| e.to_string())?;
173 p.write_raw(&frame_json(r#"{"type":"cancel","id":999}"#))
174 .map_err(|e| e.to_string())?;
175 for (req, want) in [
176 (r#"{"type":"describe","id":41}"#, "describe"),
177 (r#"{"type":"frobnicate","id":42}"#, "error"),
178 ] {
179 p.write_raw(&frame_json(req)).map_err(|e| e.to_string())?;
180 let v = p.recv_raw(TIMEOUT).map_err(|e| e.to_string())?;
181 let id = req[req.len() - 3..req.len() - 1].parse::<u64>().unwrap();
182 if v["type"] != want || v["id"] != id {
183 return Err(format!(
184 "sent {req}, expected a `{want}` reply with id {id}, got {v}"
185 ));
186 }
187 }
188 p.close().map_err(|e| e.to_string())
189 })(),
190 );
191
192 check(
193 "describe is answered",
194 (|| {
195 let mut p = start(path).map_err(|e| e.to_string())?;
196 p.describe().map_err(|e| e.to_string())?;
197 p.close().map_err(|e| e.to_string())
198 })(),
199 );
200
201 if id.kind == crate::Kind::Format {
202 check(
203 "a write of an empty result set makes a valid file (or asks for options)",
204 (|| {
205 let dir =
206 std::env::temp_dir().join(format!("dre-conformance-{}-{}", id.name, std::process::id()));
207 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
208 let file = dir.join(format!("empty.{}", id.name));
209 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
210 arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int64, true),
211 arrow::datatypes::Field::new("name", arrow::datatypes::DataType::Utf8, true),
212 ]));
213 let meta = crate::msg::ResultSetMeta {
214 name: "empty".into(),
215 query: "empty".into(),
216 result_index: 1,
217 anchor: None,
218 header: None,
219 columns: Default::default(),
220 autofit: None,
221 style: None,
222 };
223 let mut p = start(path).map_err(|e| e.to_string())?;
224 p.write_begin(&file.to_string_lossy(), &id.name, Map::new(), vec![meta], None)
225 .map_err(|e| e.to_string())?;
226 let empty = arrow::array::RecordBatch::new_empty(schema.clone());
227 let result = p
228 .write_result_set(&schema, vec![empty])
229 .and_then(|_| p.write_finish());
230 let r = match result {
231 Ok(files) if files.iter().all(|f| Path::new(f).is_file()) && !files.is_empty() => Ok(()),
232 Ok(files) => Err(format!("`written` lists files that don't exist: {files:?}")),
233 Err(HostError::Plugin { kind: Some(k), .. }) if k == "config" => Ok(()),
236 Err(e) => Err(format!("an empty result set failed: {e}")),
237 };
238 let _ = p.close();
239 let _ = std::fs::remove_dir_all(&dir);
240 r
241 })(),
242 );
243 }
244
245 if id.kind == crate::Kind::Source {
246 check(
247 "describe gives the identifier quote character",
248 (|| {
249 let mut p = start(path).map_err(|e| e.to_string())?;
250 let d = p.description().map_err(|e| e.to_string())?;
251 match d.identifier_quote.as_deref() {
252 Some(q) if q.chars().count() == 1 => {}
253 Some(q) => return Err(format!("`identifier_quote` must be one character, got {q:?}")),
254 None => return Err("a source's describe reply has no `identifier_quote`".into()),
255 }
256 p.close().map_err(|e| e.to_string())
257 })(),
258 );
259 }
260
261 check(
262 "validate is advertised, answered, and refuses an option the plugin doesn't declare",
263 (|| {
264 let mut p = start(path).map_err(|e| e.to_string())?;
265 if !p.has(crate::CAP_VALIDATE) {
266 return Err("the plugin doesn't advertise `validate`".into());
267 }
268 let (_, fields) = p.describe_all().map_err(|e| e.to_string())?;
269 p.validate(Map::new()).map_err(|e| e.to_string())?;
270 let key = "dre_conformance_unknown_option";
271 if fields.iter().any(|f| f.name == key) {
272 return Err(format!("the plugin declares `{key}`"));
273 }
274 let errors = p
275 .validate(json!({key: true}).as_object().unwrap().clone())
276 .map_err(|e| e.to_string())?;
277 if !errors.iter().any(|e| e.contains(key)) {
278 return Err(format!("an unknown option was accepted: {errors:?}"));
279 }
280 p.close().map_err(|e| e.to_string())
281 })(),
282 );
283
284 if id.kind != crate::Kind::Format {
285 check(
286 "validate_connection is answered without connecting, and an unknown key only warns",
287 (|| {
288 let mut p = start(path).map_err(|e| e.to_string())?;
289 let key = "dre_conformance_unknown_key";
290 let (_, warnings) = p
291 .validate_connection(json!({key: true}).as_object().unwrap().clone(), Vec::new())
292 .map_err(|e| e.to_string())?;
293 if !warnings.iter().any(|w| w.contains(key)) {
294 return Err(format!("an unknown key gave no warning: {warnings:?}"));
295 }
296 p.close().map_err(|e| e.to_string())
297 })(),
298 );
299 }
300
301 check(
302 "an unknown request gets an error reply and the plugin keeps serving",
303 (|| {
304 let mut p = start(path).map_err(|e| e.to_string())?;
305 p.write_raw(&frame_json(r#"{"type":"frobnicate"}"#))
306 .map_err(|e| e.to_string())?;
307 match p
308 .recv(Some(TIMEOUT), "an error reply")
309 .map_err(|e| e.to_string())?
310 {
311 Incoming::Json(Response::Error { .. }) => {}
312 other => return Err(format!("expected an error reply, got {other:?}")),
313 }
314 p.describe()
315 .map_err(|e| format!("plugin stopped serving after an unknown request: {e}"))?;
316 p.close().map_err(|e| e.to_string())
317 })(),
318 );
319
320 check(
321 "a request meant for another plugin kind gets an error reply",
322 (|| {
323 let mut p = start(path).map_err(|e| e.to_string())?;
324 let req = match p.info().kind {
325 crate::Kind::Source => Request::Finish {},
326 _ => Request::Check {
327 sql: "select 1".into(),
328 },
329 };
330 p.send(&req).map_err(|e| e.to_string())?;
331 match p
332 .recv(Some(TIMEOUT), "an error reply")
333 .map_err(|e| e.to_string())?
334 {
335 Incoming::Json(Response::Error { .. }) => {}
336 other => return Err(format!("expected an error reply, got {other:?}")),
337 }
338 p.close().map_err(|e| e.to_string())
339 })(),
340 );
341
342 check(
343 "a malformed frame is reported or ends the plugin, never hangs",
344 (|| {
345 let mut p = start(path).map_err(|e| e.to_string())?;
346 let _ = p.write_raw(&[0, 0, 0, 2, b'X', b'!']);
347 match p.recv(Some(TIMEOUT), "a reply to a malformed frame") {
348 Ok(Incoming::Json(Response::Error { .. })) | Err(HostError::Crashed { .. }) => Ok(()),
349 Err(HostError::Timeout { .. }) => Err("the plugin hung on a malformed frame".into()),
350 other => Err(format!("unexpected reply to a malformed frame: {other:?}")),
351 }
352 })(),
353 );
354
355 let destination = start(path).ok().map(|p| {
358 let info = p.info().clone();
359 let _ = p.close();
360 info
361 });
362 if let Some(info) = destination.filter(|i| i.kind == crate::Kind::Destination) {
363 let missing = |n: &str| DeliveryFile {
364 local_path: format!("/nonexistent/dre-conformance/{n}"),
365 remote_path: Some(format!("dre-conformance/{n}")),
366 };
367 let one = missing("a.csv");
368 let mut forms = vec![(
369 "deliver with options gets a reply and the plugin keeps serving",
370 Request::Deliver {
371 local_path: Some(one.local_path),
372 remote_path: one.remote_path,
373 files: Vec::new(),
374 connection: Map::new(),
375 options: json!({"conformance": true}).as_object().unwrap().clone(),
376 message: None,
377 },
378 )];
379 if info.capabilities.iter().any(|c| c == crate::CAP_MULTI_FILE) {
380 forms.push((
381 "a multi-file deliver gets a reply and the plugin keeps serving",
382 Request::Deliver {
383 local_path: None,
384 remote_path: None,
385 files: vec![missing("a.csv"), missing("b.csv")],
386 connection: Map::new(),
387 options: Map::new(),
388 message: None,
389 },
390 ));
391 }
392 let has = |c: &str| info.capabilities.iter().any(|x| x == c);
393 if has(crate::CAP_MESSAGE) {
394 forms.push((
395 "a message deliver gets a reply and the plugin keeps serving",
396 Request::Deliver {
397 local_path: None,
398 remote_path: None,
399 files: Vec::new(),
400 connection: Map::new(),
401 options: Map::new(),
402 message: Some(crate::msg::Message {
403 title: "Conformance".into(),
404 text: "**DRE** conformance check".into(),
405 html: None,
406 path: "/nonexistent/dre-conformance/message.md".into(),
407 }),
408 },
409 ));
410 }
411 if has(crate::CAP_MESSAGE_ONLY) {
412 check(
413 "a message_only plugin also advertises message",
414 if has(crate::CAP_MESSAGE) {
415 Ok(())
416 } else {
417 Err("advertises `message_only` without `message`".into())
418 },
419 );
420 check(
421 "a message_only plugin refuses a file deliver and keeps serving",
422 (|| {
423 let mut p = start(path).map_err(|e| e.to_string())?;
424 let f = missing("a.csv");
425 p.send(&Request::Deliver {
426 local_path: Some(f.local_path),
427 remote_path: f.remote_path,
428 files: Vec::new(),
429 connection: Map::new(),
430 options: Map::new(),
431 message: None,
432 })
433 .map_err(|e| e.to_string())?;
434 match p
435 .recv(Some(TIMEOUT), "a deliver reply")
436 .map_err(|e| e.to_string())?
437 {
438 Incoming::Json(Response::Error { .. }) => {}
439 other => return Err(format!("expected an error, got {other:?}")),
440 }
441 p.describe()
442 .map_err(|e| format!("plugin stopped serving after a deliver: {e}"))?;
443 p.close().map_err(|e| e.to_string())
444 })(),
445 );
446 }
447 for (name, req) in forms {
448 check(
449 name,
450 (|| {
451 let mut p = start(path).map_err(|e| e.to_string())?;
452 p.send(&req).map_err(|e| e.to_string())?;
453 match p
454 .recv(Some(TIMEOUT), "a deliver reply")
455 .map_err(|e| e.to_string())?
456 {
457 Incoming::Json(Response::Delivered { .. } | Response::Error { .. }) => {}
458 other => return Err(format!("expected delivered or error, got {other:?}")),
459 }
460 p.describe()
461 .map_err(|e| format!("plugin stopped serving after a deliver: {e}"))?;
462 p.close().map_err(|e| e.to_string())
463 })(),
464 );
465 }
466 }
467
468 check(
469 "close ends the process with exit code 0",
470 (|| {
471 let mut p = start(path).map_err(|e| e.to_string())?;
472 p.send(&Request::Close {}).map_err(|e| e.to_string())?;
473 let _ = p.recv(Some(TIMEOUT), "the close reply");
474 match p.wait_exit(TIMEOUT) {
475 Some(s) if s.success() => Ok(()),
476 Some(s) => Err(format!("exited with {s}")),
477 None => Err("still running after close".into()),
478 }
479 })(),
480 );
481
482 check(
483 "end of input ends the process",
484 (|| {
485 let mut p = start(path).map_err(|e| e.to_string())?;
486 match p.wait_exit(TIMEOUT) {
487 Some(_) => Ok(()),
488 None => Err("still running after stdin closed".into()),
489 }
490 })(),
491 );
492
493 out
494}
495
496pub fn assert_conforms(path: &Path) {
498 let checks = run(path);
499 let failed: Vec<String> = checks
500 .iter()
501 .filter(|c| !c.passed)
502 .map(|c| format!(" ✗ {}: {}: {}", c.plugin, c.name, c.detail))
503 .collect();
504 assert!(
505 failed.is_empty(),
506 "{} fails protocol conformance:\n{}",
507 path.display(),
508 failed.join("\n")
509 );
510}
511
512fn frame_json(body: &str) -> Vec<u8> {
513 let mut v = Vec::new();
514 v.extend_from_slice(&((body.len() + 1) as u32).to_be_bytes());
515 v.push(crate::frame::JSON_TAG);
516 v.extend_from_slice(body.as_bytes());
517 v
518}