1use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21
22use super::effective_policy::policy_digest;
23use crate::ValidationError;
24
25pub const MANAGED_CONFIG_SCHEMA: &str = "leanctx.managed-config/v1";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ManagedUpdateModeV1 {
31 Mdm,
33 Hive,
35 User,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ManagedConfigV1 {
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub hive_url: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub hive_root_key: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub hive_org: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub hive_idp_hint: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub hive_require: Option<bool>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub workspace_vaults: Vec<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub updates_mode: Option<ManagedUpdateModeV1>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub updates_feed_base: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub telemetry_metrics_upload: Option<bool>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub telemetry_receipts_upload: Option<bool>,
69}
70
71#[derive(Clone, Copy)]
72enum Kind {
73 Text,
74 Origin,
75 Https,
76 RootKey,
77 Id,
78 Bool,
79 List,
80 Mode,
81}
82
83const MEMBERS: &[(&str, Kind)] = &[
85 ("hive.url", Kind::Origin),
86 ("hive.root_key", Kind::RootKey),
87 ("hive.org", Kind::Id),
88 ("hive.idp_hint", Kind::Text),
89 ("hive.require", Kind::Bool),
90 ("workspace.vaults", Kind::List),
91 ("updates.mode", Kind::Mode),
92 ("updates.feed_base", Kind::Https),
93 ("telemetry.metrics_upload", Kind::Bool),
94 ("telemetry.receipts_upload", Kind::Bool),
95];
96
97impl ManagedConfigV1 {
98 pub fn from_members(members: &Map<String, Value>) -> Result<Self, ValidationError> {
101 let mut flat = Map::new();
102 for (key, value) in members {
103 if key == "schema" {
104 if value.as_str() != Some(MANAGED_CONFIG_SCHEMA) {
105 return Err(ValidationError::new(format!(
106 "schema must be \"{MANAGED_CONFIG_SCHEMA}\""
107 )));
108 }
109 continue;
110 }
111 flatten(key, value, &mut flat)?;
112 }
113 let mut out = Map::new();
114 for (name, value) in flat {
115 let Some((_, kind)) = MEMBERS.iter().find(|(member, _)| *member == name) else {
116 return Err(ValidationError::new(format!("unknown member `{name}`")));
117 };
118 let value = coerce(&name, *kind, value)?;
119 out.insert(name.replace('.', "_"), value);
120 }
121 serde_json::from_value(Value::Object(out))
122 .map_err(|error| ValidationError::new(format!("managed configuration: {error}")))
123 }
124
125 pub fn is_empty(&self) -> bool {
127 self == &Self::default()
128 }
129
130 pub fn members(&self) -> Vec<(&'static str, String)> {
133 let Ok(Value::Object(map)) = serde_json::to_value(self) else {
134 return Vec::new();
135 };
136 MEMBERS
137 .iter()
138 .filter_map(|(name, _)| {
139 let value = map.get(&name.replace('.', "_"))?;
140 let text = match value {
141 Value::String(text) => text.clone(),
142 Value::Array(items) => items
143 .iter()
144 .filter_map(Value::as_str)
145 .collect::<Vec<_>>()
146 .join(", "),
147 other => other.to_string(),
148 };
149 Some((*name, text))
150 })
151 .collect()
152 }
153
154 pub fn sets(&self, member: &str) -> bool {
156 self.members().iter().any(|(name, _)| *name == member)
157 }
158
159 pub fn digest(&self) -> String {
162 let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
163 if let Value::Object(map) = &mut value {
164 map.insert("schema".into(), Value::from(MANAGED_CONFIG_SCHEMA));
165 }
166 policy_digest(&value).unwrap_or_default()
167 }
168}
169
170fn flatten(
172 prefix: &str,
173 value: &Value,
174 out: &mut Map<String, Value>,
175) -> Result<(), ValidationError> {
176 match value {
177 Value::Object(inner) => {
178 for (key, value) in inner {
179 flatten(&format!("{prefix}.{key}"), value, out)?;
180 }
181 Ok(())
182 }
183 other => {
184 if out.insert(prefix.to_string(), other.clone()).is_some() {
185 return Err(ValidationError::new(format!(
186 "member `{prefix}` is set twice"
187 )));
188 }
189 Ok(())
190 }
191 }
192}
193
194fn coerce(name: &str, kind: Kind, value: Value) -> Result<Value, ValidationError> {
195 let refuse = |what: &str| ValidationError::new(format!("member `{name}` must be {what}"));
196 let text = |value: &Value| -> Result<String, ValidationError> {
197 match value.as_str().map(str::trim) {
198 Some(text) if !text.is_empty() && text.len() <= 512 => Ok(text.to_string()),
199 _ => Err(refuse("a non-empty string")),
200 }
201 };
202 Ok(match kind {
203 Kind::Text => Value::from(text(&value)?),
204 Kind::Origin => {
205 let url = text(&value)?;
206 if !is_origin(&url) {
207 return Err(refuse(
208 "an https origin (http only for loopback), without path or query",
209 ));
210 }
211 Value::from(url)
212 }
213 Kind::Https => {
214 let url = text(&value)?;
215 if !url.starts_with("https://") || url.len() <= "https://".len() {
216 return Err(refuse("an https URL"));
217 }
218 Value::from(url)
219 }
220 Kind::RootKey => {
221 let key = text(&value)?;
222 let base64 = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/';
223 if key.len() != 44 || !key.ends_with('=') || !key[..43].chars().all(base64) {
224 return Err(refuse(
225 "a standard base64 Ed25519 public key (44 characters)",
226 ));
227 }
228 Value::from(key)
229 }
230 Kind::Id => {
231 let id = text(&value)?;
232 if id.len() > 128 || id.chars().any(char::is_whitespace) {
233 return Err(refuse("an org id without whitespace"));
234 }
235 Value::from(id)
236 }
237 Kind::Bool => Value::from(match &value {
238 Value::Bool(flag) => *flag,
239 Value::Number(number) if number.as_u64() == Some(0) => false,
240 Value::Number(number) if number.as_u64() == Some(1) => true,
241 Value::String(text) if matches!(text.trim(), "true" | "1") => true,
242 Value::String(text) if matches!(text.trim(), "false" | "0") => false,
243 _ => return Err(refuse("true or false (or 1/0)")),
244 }),
245 Kind::List => {
246 let items: Vec<Value> = match &value {
247 Value::Array(items) => items.clone(),
248 Value::String(joined) => joined
249 .split([',', '\n'])
250 .filter(|part| !part.trim().is_empty())
251 .map(|part| Value::from(part.trim()))
252 .collect(),
253 _ => return Err(refuse("a list of strings")),
254 };
255 let items = items.iter().map(text).collect::<Result<Vec<_>, _>>()?;
256 Value::from(items)
257 }
258 Kind::Mode => {
259 let mode = text(&value)?;
260 if !matches!(mode.as_str(), "mdm" | "hive" | "user") {
261 return Err(refuse("one of mdm, hive, user"));
262 }
263 Value::from(mode)
264 }
265 })
266}
267
268fn is_origin(url: &str) -> bool {
271 let (scheme, rest) = match url.split_once("://") {
272 Some(parts) => parts,
273 None => return false,
274 };
275 let authority = rest.strip_suffix('/').unwrap_or(rest);
276 if authority.is_empty() || authority.contains(['/', '?', '#', '@', ' ']) {
277 return false;
278 }
279 let host = authority
280 .rsplit_once(':')
281 .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()) && !authority.ends_with(']'))
282 .map_or(authority, |(host, _)| host);
283 match scheme {
284 "https" => true,
285 "http" => matches!(host, "localhost" | "127.0.0.1" | "[::1]"),
286 _ => false,
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ManagedCarrierKindV1 {
294 MacosProfile,
295 WindowsPolicy,
296 LinuxFile,
297 #[serde(other)]
298 Unknown,
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use serde_json::json;
305
306 fn parse(value: Value) -> Result<ManagedConfigV1, ValidationError> {
307 match value {
308 Value::Object(map) => ManagedConfigV1::from_members(&map),
309 _ => unreachable!(),
310 }
311 }
312
313 const KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
314
315 #[test]
316 fn nested_and_dotted_carriers_mean_the_same_configuration() {
317 let nested = parse(json!({
318 "schema": MANAGED_CONFIG_SCHEMA,
319 "hive": {"url": "https://hive.acme.example", "root_key": KEY, "org": "org_1", "require": true},
320 "workspace": {"vaults": ["acme/main"]},
321 "updates": {"mode": "mdm"},
322 "telemetry": {"metrics_upload": false},
323 }))
324 .expect("valid");
325 let dotted = parse(json!({
327 "hive.url": "https://hive.acme.example",
328 "hive.root_key": KEY,
329 "hive.org": "org_1",
330 "hive.require": 1,
331 "workspace.vaults": "acme/main",
332 "updates.mode": "mdm",
333 "telemetry.metrics_upload": 0,
334 }))
335 .expect("valid");
336 assert_eq!(nested, dotted);
337 assert_eq!(nested.digest(), dotted.digest());
338 assert!(nested.digest().starts_with("sha256:"));
339 assert!(nested.sets("hive.require") && !nested.sets("hive.idp_hint"));
340 assert_eq!(
341 nested.members()[0],
342 ("hive.url", "https://hive.acme.example".into())
343 );
344 }
345
346 #[test]
347 fn a_configuration_out_of_shape_is_refused_whole() {
348 for bad in [
349 json!({"schema": "leanctx.managed-config/v2"}),
350 json!({"hive": {"rquire": true}}),
351 json!({"hive.url": "http://hive.acme.example"}),
352 json!({"hive.url": "https://hive.acme.example/v1"}),
353 json!({"hive.root_key": "not-a-key"}),
354 json!({"hive.require": "yes"}),
355 json!({"hive": {"org": "a"}, "hive.org": "b"}),
356 json!({"updates.mode": "auto"}),
357 json!({"updates.feed_base": "http://feed.example"}),
358 json!({"hive.org": "org 1"}),
359 ] {
360 assert!(parse(bad.clone()).is_err(), "accepted {bad}");
361 }
362 assert!(parse(json!({"hive.url": "http://127.0.0.1:8080"})).is_ok());
363 assert!(parse(json!({})).expect("empty").is_empty());
364 }
365}