shellcanvas_adapter_sdk/
package.rs1use 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}