返回 CodeWhale
plugin_config.rs
根目录 / crates / tui / src / extension_host / plugin_config.rs
1 //! Plugin configuration: what a plugin's `apply(ctx, config)` receives.
2 //!
3 //! The source is `[plugins."<name>".config]` in the user's `config.toml`
4 //! ([`crate::config::PluginSettings`]); there was no per-plugin configuration
5 //! before this, so nothing is replaced. It is *user* input, not reviewed
6 //! plugin content: the user owns it and may edit it without a new review, which
7 //! is why project-scope config cannot set it and why it is delivered as data
8 //! only (the plugin's own `Config` schema, when it declares one, validates it
9 //! inside the host; Cordis applies that schema and its defaults).
10 //!
11 //! The manager holds the parsed settings. A change (read again at
12 //! `/plugin reload`) changes the config's digest, which reconcile compares with
13 //! the one each live owner was activated with: a different digest revokes the
14 //! owner and activates a new generation, like a changed plugin.
15 //!
16 //! Known limitations:
17 //! * Values are plain TOML (strings, numbers, booleans, arrays, tables); a
18 //! TOML date-time is refused. There are no secrets references: do not put a
19 //! secret here, because the plugin's code can read every value, and so can
20 //! every other plugin sharing the host process.
21 //! * Only a manual `/plugin reload` re-reads the file; a running TUI does not
22 //! watch it. A reload that cannot read or parse the file keeps the previous
23 //! settings and says so in the host diagnostics.
24 //! * A refused config (too large, wrong shape) fails that plugin's activation
25 //! with the reason, in `/plugin show`, rather than activating it with
26 //! defaults.
27
28 use std::collections::BTreeMap;
29 use std::path::PathBuf;
30
31 use serde_json::Value;
32 use sha2::{Digest, Sha256};
33
34 use crate::config::PluginSettings;
35
36 /// Largest accepted config for one plugin, serialized as JSON.
37 pub const MAX_PLUGIN_CONFIG_BYTES: usize = 16 * 1024;
38 /// Deepest nesting of tables and arrays accepted.
39 const MAX_CONFIG_DEPTH: usize = 16;
40 /// How the `toml` crate serializes a date-time (a one-key table).
41 const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
42
43 /// The config one activation is given, and its digest.
44 #[derive(Debug, Clone, PartialEq)]
45 pub(crate) struct PluginConfig {
46 /// Always a JSON object.
47 pub value: Value,
48 /// SHA-256 of `value`'s compact JSON, hex.
49 pub hash: String,
50 }
51
52 impl PluginConfig {
53 fn new(value: Value) -> Self {
54 let hash = super::hex(Sha256::digest(
55 serde_json::to_vec(&value).expect("a JSON value serializes"),
56 ));
57 Self { value, hash }
58 }
59
60 /// What a plugin with no settings is given (and what a built-in module is).
61 pub(crate) fn empty() -> Self {
62 Self::new(Value::Object(serde_json::Map::new()))
63 }
64 }
65
66 /// Settings for every plugin by manifest name, and where they were read from.
67 #[derive(Default)]
68 pub(crate) struct PluginConfigs {
69 by_name: BTreeMap<String, Result<PluginConfig, String>>,
70 /// The user config file `/plugin reload` reads `[plugins]` from again.
71 source: Option<PathBuf>,
72 }
73
74 fn depth_ok(value: &Value, depth: usize) -> bool {
75 depth <= MAX_CONFIG_DEPTH
76 && match value {
77 Value::Array(items) => items.iter().all(|item| depth_ok(item, depth + 1)),
78 Value::Object(map) => map.values().all(|item| depth_ok(item, depth + 1)),
79 _ => true,
80 }
81 }
82
83 fn has_datetime(value: &Value) -> bool {
84 match value {
85 Value::Array(items) => items.iter().any(has_datetime),
86 Value::Object(map) => map.contains_key(TOML_DATETIME_KEY) || map.values().any(has_datetime),
87 _ => false,
88 }
89 }
90
91 /// One plugin's `[plugins."<name>".config]`, checked.
92 fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
93 let Some(table) = &settings.config else {
94 return Ok(PluginConfig::empty());
95 };
96 let value = serde_json::to_value(table)
97 .map_err(|error| format!("plugin `{name}` config is not representable as JSON: {error}"))?;
98 if has_datetime(&value) {
99 return Err(format!(
100 "plugin `{name}` config holds a TOML date-time; use a string"
101 ));
102 }
103 if !depth_ok(&value, 1) {
104 return Err(format!(
105 "plugin `{name}` config nests deeper than {MAX_CONFIG_DEPTH} levels"
106 ));
107 }
108 let size = serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len());
109 if size > MAX_PLUGIN_CONFIG_BYTES {
110 return Err(format!(
111 "plugin `{name}` config is {size} bytes, over the {MAX_PLUGIN_CONFIG_BYTES}-byte limit"
112 ));
113 }
114 Ok(PluginConfig::new(value))
115 }
116
117 impl PluginConfigs {
118 /// Replace every plugin's settings. `source` (when given) is where a
119 /// later reload reads them from.
120 pub fn replace(
121 &mut self,
122 settings: &BTreeMap<String, PluginSettings>,
123 source: Option<PathBuf>,
124 ) {
125 self.by_name = settings
126 .iter()
127 .map(|(name, settings)| (name.clone(), convert(name, settings)))
128 .collect();
129 if source.is_some() {
130 self.source = source;
131 }
132 }
133
134 /// Replace the settings read again from [`Self::source`]'s file.
135 pub fn replace_reloaded(&mut self, settings: &BTreeMap<String, PluginSettings>) {
136 self.replace(settings, None);
137 }
138
139 /// The file to re-read, if one was named at boot.
140 pub fn source(&self) -> Option<PathBuf> {
141 self.source.clone()
142 }
143
144 /// What `name` is activated with, or why it must not be.
145 pub fn select(&self, name: &str) -> Result<PluginConfig, String> {
146 self.by_name
147 .get(name)
148 .cloned()
149 .unwrap_or_else(|| Ok(PluginConfig::empty()))
150 }
151
152 /// The top-level keys configured for `name` (never the values), or the
153 /// reason its config is refused. `None` when it has no settings.
154 pub fn summary(&self, name: &str) -> Option<Result<Vec<String>, String>> {
155 match self.by_name.get(name)? {
156 Ok(config) => {
157 let keys: Vec<String> = config
158 .value
159 .as_object()
160 .map(|map| map.keys().cloned().collect())
161 .unwrap_or_default();
162 (!keys.is_empty()).then_some(Ok(keys))
163 }
164 Err(reason) => Some(Err(reason.clone())),
165 }
166 }
167 }
168
169 /// A digest stable across reloads for the activation compare: the config's
170 /// own digest, or, for a refused config, one derived from the reason so the
171 /// refused owner is neither retried every turn nor kept once the file changes.
172 pub(crate) fn activation_hash(selection: &Result<PluginConfig, String>) -> String {
173 match selection {
174 Ok(config) => config.hash.clone(),
175 Err(reason) => format!("refused:{}", super::hex(Sha256::digest(reason.as_bytes()))),
176 }
177 }
178
178 lines RUST