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