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
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
100/// Every check, for one plugin. `ask`: name it in the handshake (a package's plugins).
101fn 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                    // A format that needs options (fixed_width's `columns`) or a template context
235                    // refuses as a config problem.
236                    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    // Destinations: `deliver` with options (and, with `multi_file`, several files) is parsed and
357    // answered. The file doesn't exist, so a delivered or an error reply are both fine.
358    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
497/// Panic with a readable report unless every check passes. For use in plugin tests.
498///
499/// # Panics
500///
501/// Panics when a check fails: that is how it fails the test.
502pub 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}