use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::effective_policy::policy_digest;
use crate::ValidationError;
pub const MANAGED_CONFIG_SCHEMA: &str = "leanctx.managed-config/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManagedUpdateModeV1 {
Mdm,
Hive,
User,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManagedConfigV1 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hive_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hive_root_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hive_org: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hive_idp_hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hive_require: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_vaults: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates_mode: Option<ManagedUpdateModeV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates_feed_base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telemetry_metrics_upload: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telemetry_receipts_upload: Option<bool>,
}
#[derive(Clone, Copy)]
enum Kind {
Text,
Origin,
Https,
RootKey,
Id,
Bool,
List,
Mode,
}
const MEMBERS: &[(&str, Kind)] = &[
("hive.url", Kind::Origin),
("hive.root_key", Kind::RootKey),
("hive.org", Kind::Id),
("hive.idp_hint", Kind::Text),
("hive.require", Kind::Bool),
("workspace.vaults", Kind::List),
("updates.mode", Kind::Mode),
("updates.feed_base", Kind::Https),
("telemetry.metrics_upload", Kind::Bool),
("telemetry.receipts_upload", Kind::Bool),
];
impl ManagedConfigV1 {
pub fn from_members(members: &Map<String, Value>) -> Result<Self, ValidationError> {
let mut flat = Map::new();
for (key, value) in members {
if key == "schema" {
if value.as_str() != Some(MANAGED_CONFIG_SCHEMA) {
return Err(ValidationError::new(format!(
"schema must be \"{MANAGED_CONFIG_SCHEMA}\""
)));
}
continue;
}
flatten(key, value, &mut flat)?;
}
let mut out = Map::new();
for (name, value) in flat {
let Some((_, kind)) = MEMBERS.iter().find(|(member, _)| *member == name) else {
return Err(ValidationError::new(format!("unknown member `{name}`")));
};
let value = coerce(&name, *kind, value)?;
out.insert(name.replace('.', "_"), value);
}
serde_json::from_value(Value::Object(out))
.map_err(|error| ValidationError::new(format!("managed configuration: {error}")))
}
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
pub fn members(&self) -> Vec<(&'static str, String)> {
let Ok(Value::Object(map)) = serde_json::to_value(self) else {
return Vec::new();
};
MEMBERS
.iter()
.filter_map(|(name, _)| {
let value = map.get(&name.replace('.', "_"))?;
let text = match value {
Value::String(text) => text.clone(),
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
};
Some((*name, text))
})
.collect()
}
pub fn sets(&self, member: &str) -> bool {
self.members().iter().any(|(name, _)| *name == member)
}
pub fn digest(&self) -> String {
let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
if let Value::Object(map) = &mut value {
map.insert("schema".into(), Value::from(MANAGED_CONFIG_SCHEMA));
}
policy_digest(&value).unwrap_or_default()
}
}
fn flatten(
prefix: &str,
value: &Value,
out: &mut Map<String, Value>,
) -> Result<(), ValidationError> {
match value {
Value::Object(inner) => {
for (key, value) in inner {
flatten(&format!("{prefix}.{key}"), value, out)?;
}
Ok(())
}
other => {
if out.insert(prefix.to_string(), other.clone()).is_some() {
return Err(ValidationError::new(format!(
"member `{prefix}` is set twice"
)));
}
Ok(())
}
}
}
fn coerce(name: &str, kind: Kind, value: Value) -> Result<Value, ValidationError> {
let refuse = |what: &str| ValidationError::new(format!("member `{name}` must be {what}"));
let text = |value: &Value| -> Result<String, ValidationError> {
match value.as_str().map(str::trim) {
Some(text) if !text.is_empty() && text.len() <= 512 => Ok(text.to_string()),
_ => Err(refuse("a non-empty string")),
}
};
Ok(match kind {
Kind::Text => Value::from(text(&value)?),
Kind::Origin => {
let url = text(&value)?;
if !is_origin(&url) {
return Err(refuse(
"an https origin (http only for loopback), without path or query",
));
}
Value::from(url)
}
Kind::Https => {
let url = text(&value)?;
if !url.starts_with("https://") || url.len() <= "https://".len() {
return Err(refuse("an https URL"));
}
Value::from(url)
}
Kind::RootKey => {
let key = text(&value)?;
let base64 = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/';
if key.len() != 44 || !key.ends_with('=') || !key[..43].chars().all(base64) {
return Err(refuse(
"a standard base64 Ed25519 public key (44 characters)",
));
}
Value::from(key)
}
Kind::Id => {
let id = text(&value)?;
if id.len() > 128 || id.chars().any(char::is_whitespace) {
return Err(refuse("an org id without whitespace"));
}
Value::from(id)
}
Kind::Bool => Value::from(match &value {
Value::Bool(flag) => *flag,
Value::Number(number) if number.as_u64() == Some(0) => false,
Value::Number(number) if number.as_u64() == Some(1) => true,
Value::String(text) if matches!(text.trim(), "true" | "1") => true,
Value::String(text) if matches!(text.trim(), "false" | "0") => false,
_ => return Err(refuse("true or false (or 1/0)")),
}),
Kind::List => {
let items: Vec<Value> = match &value {
Value::Array(items) => items.clone(),
Value::String(joined) => joined
.split([',', '\n'])
.filter(|part| !part.trim().is_empty())
.map(|part| Value::from(part.trim()))
.collect(),
_ => return Err(refuse("a list of strings")),
};
let items = items.iter().map(text).collect::<Result<Vec<_>, _>>()?;
Value::from(items)
}
Kind::Mode => {
let mode = text(&value)?;
if !matches!(mode.as_str(), "mdm" | "hive" | "user") {
return Err(refuse("one of mdm, hive, user"));
}
Value::from(mode)
}
})
}
fn is_origin(url: &str) -> bool {
let (scheme, rest) = match url.split_once("://") {
Some(parts) => parts,
None => return false,
};
let authority = rest.strip_suffix('/').unwrap_or(rest);
if authority.is_empty() || authority.contains(['/', '?', '#', '@', ' ']) {
return false;
}
let host = authority
.rsplit_once(':')
.filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()) && !authority.ends_with(']'))
.map_or(authority, |(host, _)| host);
match scheme {
"https" => true,
"http" => matches!(host, "localhost" | "127.0.0.1" | "[::1]"),
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManagedCarrierKindV1 {
MacosProfile,
WindowsPolicy,
LinuxFile,
#[serde(other)]
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse(value: Value) -> Result<ManagedConfigV1, ValidationError> {
match value {
Value::Object(map) => ManagedConfigV1::from_members(&map),
_ => unreachable!(),
}
}
const KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
#[test]
fn nested_and_dotted_carriers_mean_the_same_configuration() {
let nested = parse(json!({
"schema": MANAGED_CONFIG_SCHEMA,
"hive": {"url": "https://hive.acme.example", "root_key": KEY, "org": "org_1", "require": true},
"workspace": {"vaults": ["acme/main"]},
"updates": {"mode": "mdm"},
"telemetry": {"metrics_upload": false},
}))
.expect("valid");
let dotted = parse(json!({
"hive.url": "https://hive.acme.example",
"hive.root_key": KEY,
"hive.org": "org_1",
"hive.require": 1,
"workspace.vaults": "acme/main",
"updates.mode": "mdm",
"telemetry.metrics_upload": 0,
}))
.expect("valid");
assert_eq!(nested, dotted);
assert_eq!(nested.digest(), dotted.digest());
assert!(nested.digest().starts_with("sha256:"));
assert!(nested.sets("hive.require") && !nested.sets("hive.idp_hint"));
assert_eq!(
nested.members()[0],
("hive.url", "https://hive.acme.example".into())
);
}
#[test]
fn a_configuration_out_of_shape_is_refused_whole() {
for bad in [
json!({"schema": "leanctx.managed-config/v2"}),
json!({"hive": {"rquire": true}}),
json!({"hive.url": "http://hive.acme.example"}),
json!({"hive.url": "https://hive.acme.example/v1"}),
json!({"hive.root_key": "not-a-key"}),
json!({"hive.require": "yes"}),
json!({"hive": {"org": "a"}, "hive.org": "b"}),
json!({"updates.mode": "auto"}),
json!({"updates.feed_base": "http://feed.example"}),
json!({"hive.org": "org 1"}),
] {
assert!(parse(bad.clone()).is_err(), "accepted {bad}");
}
assert!(parse(json!({"hive.url": "http://127.0.0.1:8080"})).is_ok());
assert!(parse(json!({})).expect("empty").is_empty());
}
}