use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use super::ai::AiConfig;
use super::compliance::ComplianceConfig;
use super::daemon::DaemonConfig;
use super::origin::OriginSpec;
use super::profile_spec::{FileStrategy, ProfileDocument};
use super::root::{CfgdConfig, ConfigMetadata, ConfigSpec};
use super::security::{ModulesConfig, SecurityConfig};
use super::source::{ConfigSourceDocument, SourceSpec};
use super::sync_secrets::SecretsConfig;
use super::theme::ThemeConfig;
use crate::PathDisplayExt;
use crate::errors::{ConfigError, Result};
const MAX_YAML_ANCHORS: usize = 256;
pub(super) fn check_yaml_anchor_limit(contents: &str, context: &Path) -> Result<()> {
let anchor_count = contents
.as_bytes()
.windows(2)
.filter(|w| w[0] == b'&' && (w[1].is_ascii_alphanumeric() || w[1] == b'_'))
.count();
if anchor_count > MAX_YAML_ANCHORS {
return Err(ConfigError::Invalid {
message: format!(
"{}: too many YAML anchors ({}, max {}) — possible anchor/alias bomb",
context.posix(),
anchor_count,
MAX_YAML_ANCHORS
),
}
.into());
}
Ok(())
}
pub(super) fn warn_on_legacy_theme_keys(raw_yaml: &str) {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(raw_yaml) else {
return;
};
let overrides = value
.get("spec")
.and_then(|s| s.get("theme"))
.and_then(|t| t.get("overrides"));
let Some(serde_yaml::Value::Mapping(m)) = overrides else {
return;
};
let removed_keys = ["subheader", "key", "value", "iconInfo"];
let renamed_keys = [
("iconSuccess", "iconOk"),
("iconWarning", "iconWarn"),
("iconError", "iconFail"),
];
for k in removed_keys {
if m.contains_key(serde_yaml::Value::String(k.into())) {
tracing::warn!(
"config: theme.overrides.{k} is no longer supported (capability removed in v0.4); \
the field will be ignored. Remove it from your cfgd.yaml."
);
}
}
for (old, new) in renamed_keys {
if m.contains_key(serde_yaml::Value::String(old.into())) {
tracing::warn!(
"config: theme.overrides.{old} is renamed to {new}; \
the old name still works for now but will be removed in a future release."
);
}
}
}
pub fn parse_config_source(contents: &str) -> Result<ConfigSourceDocument> {
check_yaml_anchor_limit(contents, Path::new("ConfigSource"))?;
let doc: ConfigSourceDocument = serde_yaml::from_str(contents).map_err(ConfigError::from)?;
if doc.kind != "ConfigSource" {
return Err(ConfigError::Invalid {
message: format!("expected kind 'ConfigSource', got '{}'", doc.kind),
}
.into());
}
Ok(doc)
}
pub fn load_config(path: &Path) -> Result<CfgdConfig> {
if !path.exists() {
return Err(ConfigError::NotFound {
path: path.to_path_buf(),
}
.into());
}
const MAX_CONFIG_SIZE: u64 = 50 * 1024 * 1024; if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_CONFIG_SIZE
{
return Err(ConfigError::Invalid {
message: format!(
"{} is too large ({} bytes, max {})",
path.posix(),
meta.len(),
MAX_CONFIG_SIZE
),
}
.into());
}
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::Invalid {
message: format!("failed to read {}: {}", path.posix(), e),
})?;
parse_config(&contents, path)
}
pub fn parse_config(contents: &str, path: &Path) -> Result<CfgdConfig> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
if ext != "toml" {
check_yaml_anchor_limit(contents, path)?;
warn_on_legacy_theme_keys(contents);
}
let raw: RawCfgdConfig = match ext {
"toml" => toml::from_str(contents).map_err(ConfigError::from)?,
_ => serde_yaml::from_str(contents).map_err(ConfigError::from)?,
};
let origin = match raw.spec.origin {
Some(RawOrigin::Single(o)) => vec![o],
Some(RawOrigin::Multiple(v)) => v,
None => vec![],
};
Ok(CfgdConfig {
api_version: raw.api_version,
kind: raw.kind,
metadata: raw.metadata,
spec: ConfigSpec {
profile: raw.spec.profile,
origin,
daemon: raw.spec.daemon,
secrets: raw.spec.secrets,
sources: raw.spec.sources,
theme: raw.spec.theme,
modules: raw.spec.modules,
file_strategy: raw.spec.file_strategy,
security: raw.spec.security,
aliases: raw.spec.aliases,
ai: raw.spec.ai,
compliance: raw.spec.compliance,
},
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawCfgdConfig {
api_version: String,
kind: String,
metadata: ConfigMetadata,
spec: RawConfigSpec,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawConfigSpec {
#[serde(default)]
profile: Option<String>,
#[serde(default)]
origin: Option<RawOrigin>,
#[serde(default)]
daemon: Option<DaemonConfig>,
#[serde(default)]
secrets: Option<SecretsConfig>,
#[serde(default)]
sources: Vec<SourceSpec>,
#[serde(default)]
theme: Option<ThemeConfig>,
#[serde(default)]
modules: Option<ModulesConfig>,
#[serde(default)]
file_strategy: FileStrategy,
#[serde(default)]
security: Option<SecurityConfig>,
#[serde(default)]
aliases: HashMap<String, String>,
#[serde(default)]
ai: Option<AiConfig>,
#[serde(default)]
compliance: Option<ComplianceConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawOrigin {
Single(OriginSpec),
Multiple(Vec<OriginSpec>),
}
pub fn load_profile(path: &Path) -> Result<ProfileDocument> {
if !path.exists() {
return Err(ConfigError::NotFound {
path: path.to_path_buf(),
}
.into());
}
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::Invalid {
message: format!("failed to read {}: {}", path.posix(), e),
})?;
check_yaml_anchor_limit(&contents, path)?;
let doc: ProfileDocument = serde_yaml::from_str(&contents).map_err(ConfigError::from)?;
Ok(doc)
}
pub(super) fn find_profile_path(profiles_dir: &Path, name: &str) -> PathBuf {
let yaml_path = profiles_dir.join(format!("{}.yaml", name));
if yaml_path.exists() {
return yaml_path;
}
let yml_path = profiles_dir.join(format!("{}.yml", name));
if yml_path.exists() {
return yml_path;
}
yaml_path
}