Skip to main content

mars_schema/
plugin_metadata.rs

1//! Pure validation for Mars-generated plugin tree markers.
2
3use 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/// A package-root file Mars generates and owns.
11///
12/// `GENERATED_ROOT_FILES` is the only list of them: publishing excludes these
13/// paths from authored-source walks, and sentinel validation accepts only them.
14/// A tree row names one of these constants directly; `build::plugin::spec`'s test
15/// keeps the tree rows and this list in agreement, so a tree cannot generate a
16/// root file the list does not cover.
17#[derive(Clone, Copy)]
18pub struct GeneratedRootFile {
19    path: &'static str,
20    /// The catalog directory holding a marketplace file; `None` for a package
21    /// manifest at the package root.
22    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
51/// Every package-root path Mars generates: the root files and their catalog directories.
52pub 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    /// Root-file hashes this build replaces or deletes: the Mars-owned bytes
80    /// still on disk if publication died before rewriting them. Publishing the
81    /// tree and the root files takes two steps; without this record the next
82    /// build could not tell its own earlier write from a hand edit. A path
83    /// counts only while its bytes still match, so a user's own file at a
84    /// retired path is never claimed.
85    #[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/// Why a sentinel is not a marker for a generated plugin output directory.
100///
101/// Each variant names the value that failed, so a caller reports which entry is wrong
102/// without repeating the rule to find it.
103#[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    /// Why this sentinel does not describe a tree this build may treat as generated output.
126    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
176/// Whether bytes are a valid marker for a generated plugin output directory.
177pub 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
190/// Whether a dependency pins the build to something: a version, a commit, or both.
191fn records_revision(value: &Option<String>) -> bool {
192    value
193        .as_deref()
194        .is_some_and(|value| !value.trim().is_empty())
195}
196
197/// Whether a sentinel entry names a package-relative path and a sha256 hash.
198pub 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
205/// Reject roots of every target platform, not only the host's: a sentinel written
206/// on Windows must not read as relative when checked on Unix, or the reverse.
207fn 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}