mars_schema/
plugin_metadata.rs1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7pub const SENTINEL_FILE: &str = ".mars-plugin.json";
8pub const SENTINEL_SCHEMA_VERSION: u32 = 1;
9
10#[derive(Clone, Copy)]
18pub struct GeneratedRootFile {
19 path: &'static str,
20 catalog_directory: Option<&'static str>,
23}
24
25pub const CLAUDE_ROOT_FILE: GeneratedRootFile = GeneratedRootFile {
26 path: ".claude-plugin/marketplace.json",
27 catalog_directory: Some(".claude-plugin"),
28};
29pub const CURSOR_ROOT_FILE: GeneratedRootFile = GeneratedRootFile {
30 path: ".cursor-plugin/marketplace.json",
31 catalog_directory: Some(".cursor-plugin"),
32};
33pub const OPENCODE_ROOT_FILE: GeneratedRootFile = GeneratedRootFile {
34 path: "package.json",
35 catalog_directory: None,
36};
37
38pub const GENERATED_ROOT_FILES: &[GeneratedRootFile] =
39 &[CLAUDE_ROOT_FILE, CURSOR_ROOT_FILE, OPENCODE_ROOT_FILE];
40
41impl GeneratedRootFile {
42 pub fn path(self) -> &'static str {
43 self.path
44 }
45
46 pub fn catalog_directory(self) -> Option<&'static str> {
47 self.catalog_directory
48 }
49}
50
51pub fn generated_root_paths() -> impl Iterator<Item = &'static str> {
53 GENERATED_ROOT_FILES
54 .iter()
55 .flat_map(|file| std::iter::once(file.path()).chain(file.catalog_directory()))
56}
57
58pub fn is_generated_root_file(path: &str) -> bool {
59 GENERATED_ROOT_FILES.iter().any(|file| file.path() == path)
60}
61
62pub fn is_generated_root_directory(path: &str) -> bool {
63 GENERATED_ROOT_FILES
64 .iter()
65 .any(|file| file.catalog_directory() == Some(path))
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PluginSentinel {
70 pub schema_version: u32,
71 pub mars_version: String,
72 pub plugin_name: String,
73 pub version: String,
74 pub inputs_hash: String,
75 pub dependencies: BTreeMap<String, ResolvedDependency>,
76 pub files: BTreeMap<String, String>,
77 #[serde(default)]
78 pub root_files: BTreeMap<String, String>,
79 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86 pub root_files_previous: BTreeMap<String, String>,
87 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
88 pub root_directories: BTreeSet<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ResolvedDependency {
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub version: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub commit: Option<String>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
104pub enum SentinelError {
105 #[error(
106 "plugin sentinel schema version {recorded} is not supported (this build reads {})",
107 SENTINEL_SCHEMA_VERSION
108 )]
109 UnsupportedSchemaVersion { recorded: u32 },
110 #[error("plugin sentinel records an empty `{field}`")]
111 EmptyField { field: &'static str },
112 #[error("plugin sentinel dependency `{name}` records neither a version nor a commit")]
113 DependencyWithoutVersionOrCommit { name: String },
114 #[error("plugin sentinel records no generated files")]
115 NoFiles,
116 #[error("plugin sentinel entry `{path}` is not a relative path with a sha256 hash")]
117 InvalidEntry { path: String },
118 #[error("plugin sentinel records unsupported generated root file `{path}`")]
119 UnsupportedRootFile { path: String },
120 #[error("plugin sentinel records unsupported generated root directory `{path}`")]
121 UnsupportedRootDirectory { path: String },
122}
123
124impl PluginSentinel {
125 pub fn validate(&self) -> Result<(), SentinelError> {
127 if self.schema_version != SENTINEL_SCHEMA_VERSION {
128 return Err(SentinelError::UnsupportedSchemaVersion {
129 recorded: self.schema_version,
130 });
131 }
132 let required = [
133 ("mars_version", &self.mars_version),
134 ("plugin_name", &self.plugin_name),
135 ("version", &self.version),
136 ("inputs_hash", &self.inputs_hash),
137 ];
138 if let Some((field, _)) = required
139 .into_iter()
140 .find(|(_, value)| value.trim().is_empty())
141 {
142 return Err(SentinelError::EmptyField { field });
143 }
144 for (name, dependency) in &self.dependencies {
145 if !records_revision(&dependency.version) && !records_revision(&dependency.commit) {
146 return Err(SentinelError::DependencyWithoutVersionOrCommit { name: name.clone() });
147 }
148 }
149 if self.files.is_empty() {
150 return Err(SentinelError::NoFiles);
151 }
152 for (path, hash) in &self.files {
153 validate_entry(path, hash)?;
154 }
155 for (path, hash) in self.root_files.iter().chain(&self.root_files_previous) {
156 if !is_generated_root_file(path) {
157 return Err(SentinelError::UnsupportedRootFile { path: path.clone() });
158 }
159 validate_entry(path, hash)?;
160 }
161 if let Some(path) = self
162 .root_directories
163 .iter()
164 .find(|path| !is_generated_root_directory(path))
165 {
166 return Err(SentinelError::UnsupportedRootDirectory { path: path.clone() });
167 }
168 Ok(())
169 }
170
171 pub fn is_valid(&self) -> bool {
172 self.validate().is_ok()
173 }
174}
175
176pub fn is_valid_sentinel(bytes: &[u8]) -> bool {
178 serde_json::from_slice::<PluginSentinel>(bytes).is_ok_and(|sentinel| sentinel.is_valid())
179}
180
181fn validate_entry(path: &str, hash: &str) -> Result<(), SentinelError> {
182 if valid_entry(path, hash) {
183 return Ok(());
184 }
185 Err(SentinelError::InvalidEntry {
186 path: path.to_string(),
187 })
188}
189
190fn records_revision(value: &Option<String>) -> bool {
192 value
193 .as_deref()
194 .is_some_and(|value| !value.trim().is_empty())
195}
196
197pub fn valid_entry(path: &str, hash: &str) -> bool {
199 !path.is_empty()
200 && !is_absolute_on_any_platform(path)
201 && !path.split(['/', '\\']).any(|component| component == "..")
202 && is_sha256(hash)
203}
204
205fn is_absolute_on_any_platform(path: &str) -> bool {
208 path.starts_with(['/', '\\'])
209 || path.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
210 && path.as_bytes().get(1) == Some(&b':')
211}
212
213pub fn is_sha256(value: &str) -> bool {
214 value.strip_prefix("sha256:").is_some_and(|hex| {
215 hex.len() == 64
216 && hex
217 .bytes()
218 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
219 })
220}