Skip to main content

radixdb_plugin_host/
manifest.rs

1use std::path::PathBuf;
2
3use semver::Version;
4use serde::{Deserialize, Serialize};
5
6pub const PLUGIN_MANIFEST_FILE: &str = "radixdb-plugin.toml";
7pub const MANIFEST_FORMAT: u16 = 1;
8pub const SUPPORTED_TARGET: &str = "x86_64-unknown-linux-gnu";
9pub const OFFICIAL_BUILD_IMAGE: &str = "rust:1.97.0-bookworm";
10pub const MAXIMUM_REQUIRED_GLIBC: &str = "2.36";
11pub(crate) const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
12pub(crate) const MAX_LIBRARY_BYTES: u64 = 256 * 1024 * 1024;
13
14/// Explicit startup allowlist. An empty list is the compatibility default and
15/// performs no filesystem lookup.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(deny_unknown_fields)]
18pub struct PluginHostConfig {
19    #[serde(default)]
20    pub package_directories: Vec<PathBuf>,
21}
22
23/// Exact package manifest admitted by the ABI-major-1 host.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct PluginPackageManifest {
27    pub format: u16,
28    pub package_id: String,
29    pub name: String,
30    pub version: Version,
31    pub library: PathBuf,
32    pub library_sha256: String,
33    pub descriptor_fingerprint: String,
34    pub target: String,
35    pub maximum_required_glibc: String,
36    pub build_image: String,
37    pub panic_strategy: String,
38    pub abi_major: u16,
39    pub abi_min_minor: u16,
40    pub abi_max_minor: u16,
41}
42
43impl PluginPackageManifest {
44    pub fn validate_static_fields(&self) -> Result<(), String> {
45        if self.format != MANIFEST_FORMAT {
46            return Err(format!(
47                "unsupported package manifest format {}; expected {MANIFEST_FORMAT}",
48                self.format
49            ));
50        }
51        if !self.version.build.is_empty() {
52            return Err("package version must be canonical SemVer without build metadata".into());
53        }
54        if !is_canonical_name(&self.name) {
55            return Err("package name must match [a-z][a-z0-9_.-]*".into());
56        }
57        if self.target != SUPPORTED_TARGET {
58            return Err(format!(
59                "unsupported target {}; expected {SUPPORTED_TARGET}",
60                self.target
61            ));
62        }
63        let required_glibc = parse_glibc_version(&self.maximum_required_glibc)
64            .ok_or_else(|| "maximum_required_glibc must be major.minor".to_owned())?;
65        let maximum_glibc = parse_glibc_version(MAXIMUM_REQUIRED_GLIBC)
66            .expect("host contract glibc version is valid");
67        if required_glibc > maximum_glibc {
68            return Err(format!(
69                "maximum_required_glibc must not exceed {MAXIMUM_REQUIRED_GLIBC}"
70            ));
71        }
72        if self.build_image != OFFICIAL_BUILD_IMAGE {
73            return Err(format!("build_image must be {OFFICIAL_BUILD_IMAGE}"));
74        }
75        if self.panic_strategy != "unwind" {
76            return Err("panic_strategy must be unwind".into());
77        }
78        if self.abi_major != radixdb_plugin_abi::RADIX_ABI_MAJOR
79            || self.abi_min_minor > self.abi_max_minor
80            || self.abi_min_minor > radixdb_plugin_abi::RADIX_ABI_MINOR
81        {
82            return Err("package ABI range is incompatible with host ABI 1.0".into());
83        }
84        validate_lower_hex(&self.library_sha256, 32, "library_sha256")?;
85        validate_lower_hex(&self.descriptor_fingerprint, 32, "descriptor_fingerprint")?;
86        validate_library_path(&self.library)?;
87        Ok(())
88    }
89}
90
91pub(crate) fn parse_glibc_version(value: &str) -> Option<(u32, u32)> {
92    let (major, minor) = value.split_once('.')?;
93    if minor.contains('.') || major.is_empty() || minor.is_empty() {
94        return None;
95    }
96    Some((major.parse().ok()?, minor.parse().ok()?))
97}
98
99fn validate_library_path(path: &std::path::Path) -> Result<(), String> {
100    let mut components = path.components();
101    let Some(std::path::Component::Normal(directory)) = components.next() else {
102        return Err("library must be a relative lib/<name>.so path".into());
103    };
104    let Some(std::path::Component::Normal(file)) = components.next() else {
105        return Err("library must be a relative lib/<name>.so path".into());
106    };
107    if components.next().is_some()
108        || directory != "lib"
109        || file.to_str().is_none_or(|name| {
110            name.is_empty() || !name.starts_with("lib") || !name.ends_with(".so")
111        })
112    {
113        return Err("library must be exactly lib/lib<name>.so".into());
114    }
115    Ok(())
116}
117
118pub(crate) fn decode_lower_hex<const N: usize>(
119    value: &str,
120    field: &str,
121) -> Result<[u8; N], String> {
122    validate_lower_hex(value, N, field)?;
123    let mut output = [0_u8; N];
124    for (index, byte) in output.iter_mut().enumerate() {
125        let offset = index * 2;
126        *byte = u8::from_str_radix(&value[offset..offset + 2], 16)
127            .map_err(|_| format!("{field} is not lowercase hexadecimal"))?;
128    }
129    Ok(output)
130}
131
132fn validate_lower_hex(value: &str, bytes: usize, field: &str) -> Result<(), String> {
133    if value.len() != bytes * 2
134        || !value
135            .bytes()
136            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
137    {
138        return Err(format!(
139            "{field} must contain exactly {} lowercase hexadecimal characters",
140            bytes * 2
141        ));
142    }
143    Ok(())
144}
145
146pub(crate) fn parse_canonical_uuid(value: &str) -> Result<[u8; 16], String> {
147    if value.len() != 36
148        || value.as_bytes().get(8) != Some(&b'-')
149        || value.as_bytes().get(13) != Some(&b'-')
150        || value.as_bytes().get(18) != Some(&b'-')
151        || value.as_bytes().get(23) != Some(&b'-')
152    {
153        return Err("package_id must be a canonical lowercase UUID".into());
154    }
155    let compact: String = value
156        .chars()
157        .filter(|character| *character != '-')
158        .collect();
159    let id = decode_lower_hex::<16>(&compact, "package_id")?;
160    if id == [0; 16] {
161        return Err("package_id must not be zero".into());
162    }
163    Ok(id)
164}
165
166fn is_canonical_name(value: &str) -> bool {
167    let mut bytes = value.bytes();
168    matches!(bytes.next(), Some(b'a'..=b'z'))
169        && bytes.all(|byte| {
170            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-')
171        })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn manifest_rejects_noncanonical_or_unsafe_fields() {
180        let source = r#"
181format = 1
182package_id = "12345678-1234-1234-1234-123456789abc"
183name = "sample"
184version = "1.0.0"
185library = "lib/libsample.so"
186library_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
187descriptor_fingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
188target = "x86_64-unknown-linux-gnu"
189maximum_required_glibc = "2.36"
190build_image = "rust:1.97.0-bookworm"
191panic_strategy = "unwind"
192abi_major = 1
193abi_min_minor = 0
194abi_max_minor = 0
195"#;
196        let manifest: PluginPackageManifest = toml::from_str(source).unwrap();
197        manifest.validate_static_fields().unwrap();
198
199        let mut unsafe_path = manifest.clone();
200        unsafe_path.library = PathBuf::from("../libsample.so");
201        assert!(unsafe_path.validate_static_fields().is_err());
202        let mut abort = manifest;
203        abort.panic_strategy = "abort".into();
204        assert!(abort.validate_static_fields().is_err());
205    }
206}