Skip to main content

dre_protocol/
conformance.rs

1//! The protocol conformance suite. Every first-party plugin runs it in its tests; third-party
2//! plugin authors can run it too. It only checks protocol behaviour common to every plugin kind;
3//! each plugin's own tests cover what it does with real data. A package executable
4//! (`dre-plugin-<package>`) is checked once per plugin it provides.
5
6// Each check is an immediately-invoked closure so `?` works inside it.
7#![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    /// The plugin checked (`<kind>/<name>`), or the package for checks of the package itself.
24    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
48/// Run every check against the plugin executable at `path`.
49pub fn run(path: &Path) -> Vec<Check> {
50    run_with_env(path, &[])
51}
52
53/// Like [`run`], with extra environment variables for every plugin process (for plugins whose
54/// capabilities depend on their environment).
55pub 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
98/// Every check, for one plugin. `ask`: name it in the handshake (a package's plugins).
99fn 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                    // A format that needs options (fixed_width's `columns`) or a template context
234                    // refuses as a config problem.
235                    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    // Destinations: `deliver` with options (and, with `multi_file`, several files) is parsed and
356    // answered. The file doesn't exist, so a delivered or an error reply are both fine.
357    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
496/// Panic with a readable report unless every check passes. For use in plugin tests.
497pub 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}