Skip to main content

shellcanvas_adapter_sdk/
package.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Shared package identity, configuration and asset-path validation.
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashSet;
7const MAX_PACKAGE_FILES: usize = 4096;
8const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
9#[derive(Clone, Debug, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase", deny_unknown_fields)]
11pub struct PackageFile {
12    pub path: String,
13    pub size: u64,
14    pub sha256: String,
15    #[serde(default)]
16    pub executable: bool,
17}
18#[derive(Clone, Debug, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum FieldKind {
21    Text,
22    Password,
23    Number,
24    Boolean,
25}
26#[derive(Clone, Debug, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ConfigField {
29    pub id: String,
30    pub label: String,
31    pub kind: FieldKind,
32    #[serde(default)]
33    pub required: bool,
34    #[serde(default)]
35    pub default: Option<Value>,
36}
37#[derive(Clone, Debug, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase", deny_unknown_fields)]
39pub struct Manifest {
40    pub schema_version: u32,
41    pub id: String,
42    pub name: String,
43    pub version: String,
44    pub description: String,
45    pub platform: String,
46    pub entrypoint: String,
47    #[serde(default)]
48    pub arguments: Vec<String>,
49    pub files: Vec<PackageFile>,
50    #[serde(default)]
51    pub configuration: Vec<ConfigField>,
52}
53pub fn relative(path: &str) -> Result<()> {
54    if path.is_empty()
55        || path
56            .chars()
57            .any(|c| c.is_control() || "\\:<>\"|?*".contains(c))
58    {
59        bail!("Invalid adapter asset path");
60    }
61    for part in path.split('/') {
62        let stem = part
63            .split('.')
64            .next()
65            .unwrap_or("")
66            .trim_end_matches(' ')
67            .to_uppercase();
68        let reserved_port = stem
69            .strip_prefix("COM")
70            .or_else(|| stem.strip_prefix("LPT"))
71            .is_some_and(|suffix| {
72                ["1", "2", "3", "4", "5", "6", "7", "8", "9", "¹", "²", "³"].contains(&suffix)
73            });
74        if part.is_empty()
75            || part == "."
76            || part == ".."
77            || part.ends_with(['.', ' '])
78            || ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].contains(&stem.as_str())
79            || reserved_port
80        {
81            bail!("Invalid or reserved adapter asset path");
82        }
83    }
84    Ok(())
85}
86impl Manifest {
87    pub fn validate(&self) -> Result<()> {
88        if self.schema_version != 1
89            || !crate::wire::name(&self.id)
90            || !self.id.contains('.')
91            || self.id.starts_with("system.")
92            || self.name.trim().is_empty()
93            || self.name.len() > 200
94            || self.description.len() > 4000
95            || self.version.len() > 100
96            || semver::Version::parse(&self.version).is_err()
97        {
98            bail!("Unsupported or invalid adapter package identity");
99        }
100        relative(&self.entrypoint)?;
101        if self.arguments.iter().any(|arg| arg.contains('\0')) {
102            bail!("Invalid adapter launch argument");
103        }
104        if self.files.is_empty() || self.files.len() > MAX_PACKAGE_FILES {
105            bail!("Adapter packages must contain 1 to 4096 files");
106        }
107        let mut paths = HashSet::new();
108        let mut size = 0u64;
109        for entry in &self.files {
110            relative(&entry.path)?;
111            if !paths.insert(entry.path.to_lowercase())
112                || entry.sha256.len() != 64
113                || !entry
114                    .sha256
115                    .bytes()
116                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
117            {
118                bail!("Duplicate asset paths or invalid file hashes");
119            }
120            size = size
121                .checked_add(entry.size)
122                .context("Adapter package size overflow")?;
123            if size > MAX_PACKAGE_BYTES {
124                bail!("Adapter package exceeds the 512 MiB payload budget");
125            }
126        }
127        if !self
128            .files
129            .iter()
130            .any(|file| file.path == self.entrypoint && file.executable)
131        {
132            bail!("Adapter entrypoint must be a declared executable file");
133        }
134        if self.platform.starts_with("windows-")
135            && !self.entrypoint.to_lowercase().ends_with(".exe")
136        {
137            bail!("Windows adapter entrypoints must be explicit .exe files");
138        }
139        let mut fields = HashSet::new();
140        for field in &self.configuration {
141            if !crate::wire::name(&field.id)
142                || field.id.contains('.')
143                || !fields.insert(&field.id)
144                || field.label.trim().is_empty()
145                || field.label.len() > 200
146            {
147                bail!("Invalid or duplicate adapter configuration fields");
148            }
149            if let Some(value) = &field.default {
150                if matches!(field.kind, FieldKind::Password) || !field.accepts(value) {
151                    bail!("Invalid adapter field default; passwords cannot have packaged defaults");
152                }
153            }
154        }
155        Ok(())
156    }
157    pub fn validate_configuration(&self, value: &Value) -> Result<Value> {
158        let input = value
159            .as_object()
160            .context("Adapter configuration must be an object")?;
161        if input
162            .keys()
163            .any(|key| !self.configuration.iter().any(|field| field.id == *key))
164        {
165            bail!("Unknown adapter configuration field");
166        }
167        let mut output = serde_json::Map::new();
168        for field in &self.configuration {
169            if let Some(value) = input.get(&field.id).or(field.default.as_ref()) {
170                if !field.accepts(value)
171                    || (field.required && value.as_str().is_some_and(str::is_empty))
172                {
173                    bail!("Invalid value for {}", field.label);
174                }
175                output.insert(field.id.clone(), value.clone());
176            } else if field.required {
177                bail!("{} is required", field.label);
178            }
179        }
180        Ok(Value::Object(output))
181    }
182}
183
184impl ConfigField {
185    fn accepts(&self, value: &Value) -> bool {
186        match self.kind {
187            FieldKind::Text | FieldKind::Password => value.is_string(),
188            FieldKind::Number => value.is_number(),
189            FieldKind::Boolean => value.is_boolean(),
190        }
191    }
192}
193
194#[cfg(test)]
195mod package_limits {
196    use super::*;
197
198    fn manifest(files: Vec<PackageFile>) -> Manifest {
199        Manifest {
200            schema_version: 1,
201            id: "org.example.adapter".into(),
202            name: "Example".into(),
203            version: "1.0.0".into(),
204            description: String::new(),
205            platform: "windows-x86_64".into(),
206            entrypoint: "adapter.exe".into(),
207            arguments: vec![],
208            files,
209            configuration: vec![],
210        }
211    }
212
213    fn file(size: u64) -> PackageFile {
214        PackageFile {
215            path: "adapter.exe".into(),
216            size,
217            sha256: "a".repeat(64),
218            executable: true,
219        }
220    }
221
222    #[test]
223    fn bounds_review_payload_before_staging() {
224        assert!(manifest(vec![]).validate().is_err());
225        assert!(manifest(vec![file(MAX_PACKAGE_BYTES)]).validate().is_ok());
226        assert!(manifest(vec![file(MAX_PACKAGE_BYTES + 1)])
227            .validate()
228            .is_err());
229        assert!(manifest((0..=MAX_PACKAGE_FILES).map(|_| file(0)).collect())
230            .validate()
231            .is_err());
232    }
233}