Skip to main content

superfluid_daemon/
compat.rs

1//! `superfluid compat [--json]`: what this build was built and tested for, per runtime.
2
3use std::process::Command;
4
5use serde_json::{json, Map, Value};
6
7use crate::runtimes::Catalog;
8
9/// Every adapter this build ships: a linked one in process, a shipped worker through `compat --json`.
10pub fn json(catalog: &Catalog) -> Value {
11    let mut runtimes: Map<String, Value> = Map::new();
12    for rt in crate::linked::runtimes() {
13        runtimes.insert(rt.info().id.into(), rt.compat());
14    }
15    for id in catalog.shipped_ids() {
16        if runtimes.contains_key(id.name()) {
17            continue;
18        }
19        let Some(worker) = catalog.shipped(id) else { continue };
20        let mut cmd = Command::new(&worker);
21        crate::exec::without_secrets(&mut cmd).args(["compat", "--json"]);
22        let answer = match crate::exec::output_within(&mut cmd, crate::exec::ANSWER_WITHIN) {
23            Ok(None) => json!({"error": format!("{} compat did not answer within {}s", worker.display(), crate::exec::ANSWER_WITHIN.as_secs())}),
24            Ok(Some(out)) if out.status.success() => serde_json::from_slice::<Value>(&out.stdout)
25                .unwrap_or_else(|e| json!({"error": format!("{}: compat is not JSON: {e}", worker.display())})),
26            Ok(Some(out)) => json!({"error": format!("{} compat exited {}{}", worker.display(), out.status, crate::exec::stderr_tail(&out.stderr))}),
27            Err(e) => json!({"error": format!("{}: {e}", worker.display())}),
28        };
29        runtimes.insert(id.name().into(), answer);
30    }
31    json!({"superfluid": env!("CARGO_PKG_VERSION"), "runtimes": runtimes})
32}
33
34/// The runtimes whose shipped adapter did not answer. `superfluid compat` exits 1 when there is one, so a release
35/// whose adapter is broken fails where it writes compat.json instead of publishing an error for a host to read.
36pub fn unanswered(c: &Value) -> Vec<&str> {
37    c["runtimes"].as_object().map(|r| r.iter().filter(|(_, v)| v["error"].is_string()).map(|(id, _)| id.as_str()).collect()).unwrap_or_default()
38}
39
40/// `superfluid compat`, for a person.
41pub fn text(c: &Value) -> String {
42    let mut out = format!("{}\n", crate::cli::version());
43    let Some(runtimes) = c["runtimes"].as_object() else { return out };
44    for (id, r) in runtimes {
45        if let Some(e) = r["error"].as_str() {
46            out.push_str(&format!("{id}: {e}\n"));
47            continue;
48        }
49        let line = match id.as_str() {
50            "basert" => {
51                let abi = r["abi"].as_u64().map(|n| format!("engine ABI {n}")).unwrap_or_else(|| "no engine ABI number (a header before 0.4.0)".into());
52                let unnumbered = match r["unnumbered_minor"].as_str() {
53                    Some(minor) => format!("one that states no number serves if it is baseRT {minor}.x"),
54                    None => "one that states no number does not serve".to_string(),
55                };
56                let pinned: Vec<String> = r["pinned_engines"]
57                    .as_array()
58                    .map(|a| a.iter().filter_map(|p| p["version"].as_str().map(str::to_string)).collect())
59                    .unwrap_or_default();
60                format!(
61                    "built against baseRT {} headers, {abi}. A libbaseRT with the same ABI number serves, whatever its version; {unnumbered}. \
62                     Installs on its own: {}",
63                    r["basert_version"].as_str().unwrap_or("?"),
64                    if pinned.is_empty() { "nothing".to_string() } else { pinned.join(", ") }
65                )
66            }
67            _ => {
68                let tested: Vec<String> =
69                    r["tested"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
70                let python = r["python"].as_str().map(|p| format!(" on Python {p}")).unwrap_or_default();
71                format!("installs on its own{python}: {}", if tested.is_empty() { "nothing".to_string() } else { tested.join(", ") })
72            }
73        };
74        out.push_str(&format!("{id}: {line}\n"));
75    }
76    out
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::os::unix::fs::PermissionsExt;
83    use std::path::PathBuf;
84
85    fn scratch(name: &str) -> PathBuf {
86        let dir = std::env::temp_dir().join(format!("superfluid-compat-{name}-{}", std::process::id()));
87        let _ = std::fs::remove_dir_all(&dir);
88        std::fs::create_dir_all(&dir).unwrap();
89        dir
90    }
91
92    #[test]
93    fn every_shipped_adapter_answers_for_its_runtime() {
94        let root = scratch("shipped");
95        let beside = root.join("bin");
96        let libexec = root.join("libexec").join("superfluid");
97        std::fs::create_dir_all(&beside).unwrap();
98        std::fs::create_dir_all(&libexec).unwrap();
99        let toy = libexec.join("superfluid-worker-toyrt");
100        // The toy also tells whether a credential reached it.
101        std::fs::write(&toy, "#!/bin/sh\n[ \"$1\" = compat ] || exit 2\necho \"{\\\"tested\\\":[\\\"t2\\\",\\\"t1\\\"],\\\"saw\\\":\\\"${SUPERFLUID_COMPAT_SENTINEL_TOKEN:-}\\\"}\"\n").unwrap();
102        std::fs::set_permissions(&toy, std::fs::Permissions::from_mode(0o755)).unwrap();
103        let broken = libexec.join("superfluid-worker-oldrt");
104        std::fs::write(&broken, "#!/bin/sh\nexit 2\n").unwrap();
105        std::fs::set_permissions(&broken, std::fs::Permissions::from_mode(0o755)).unwrap();
106        let fails = libexec.join("superfluid-worker-failrt");
107        std::fs::write(&fails, "#!/bin/sh\necho 'superfluid-worker-failrt: no recipe.json beside the adapter' >&2\nexit 1\n").unwrap();
108        std::fs::set_permissions(&fails, std::fs::Permissions::from_mode(0o755)).unwrap();
109        let cat = Catalog::new(root.join("home"), Some(beside.clone()), beside.join("superfluid-workerd"));
110        std::env::set_var("SUPERFLUID_COMPAT_SENTINEL_TOKEN", "hunter2");
111        let c = json(&cat);
112        std::env::remove_var("SUPERFLUID_COMPAT_SENTINEL_TOKEN");
113        assert_eq!(c["superfluid"], env!("CARGO_PKG_VERSION"));
114        assert_eq!(c["runtimes"]["toyrt"]["tested"][0], "t2");
115        assert_eq!(c["runtimes"]["toyrt"]["saw"], "", "a credential-named variable is not handed to the worker");
116        for id in crate::linked::ids() {
117            assert!(c["runtimes"][id.name()].is_object(), "the linked {} adapter answers in process: {c}", id.name());
118        }
119        assert!(c["runtimes"]["oldrt"]["error"].as_str().unwrap().contains("exited"), "{c}");
120        assert_eq!(c["runtimes"]["failrt"]["error"].as_str().unwrap().rsplit(": ").next(), Some("no recipe.json beside the adapter"), "{c}");
121        assert_eq!(unanswered(&c), ["failrt", "oldrt"], "{c}");
122        #[cfg(feature = "basert")]
123        {
124            assert!(c["runtimes"]["basert"]["basert_version"].is_string(), "{c}");
125            assert!(c["runtimes"]["basert"]["pinned_engines"].is_array(), "{c}");
126        }
127        let t = text(&c);
128        assert!(t.contains("toyrt: installs on its own: t2, t1"), "{t}");
129        assert!(t.contains("oldrt: ") && t.contains("exited"), "{t}");
130        assert!(unanswered(&json!({"runtimes": {"toyrt": {"tested": []}}})).is_empty());
131        let _ = std::fs::remove_dir_all(&root);
132    }
133}