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