//! Requirements layers and fail-closed enforcement. use std::path::{Path, PathBuf}; use crate::env_bool; use crate::loader::{apply_version_overrides_with_registered, load_toml_file}; use crate::paths::{system_config_dir, user_kigi_home}; use crate::version_overrides::{VersionOverrideError, apply_version_overrides}; /// The `requirements.toml` opt-in key for strict (fail-closed) enforcement. /// Lives next to the signed payload that carries the flag /// ([`crate::signed_policy::SignedPayload::fail_closed`]) so the two sides /// can't drift. pub const FAIL_CLOSED_KEY: &str = "fail_closed"; /// Read the `fail_closed` opt-in from a requirements-TOML string — THE canonical /// parse shared by every caller so the semantics can't drift. /// Invalid TOML or a non-bool value → `false`. pub fn fail_closed_flag_from_str(requirements: &str) -> bool { toml::from_str::(requirements) .ok() .and_then(|v| v.get(FAIL_CLOSED_KEY).and_then(toml::Value::as_bool)) .unwrap_or(false) } /// Read the `fail_closed` opt-in from a parsed requirements layer — same semantics as /// [`fail_closed_flag_from_str`]. Env tightening (file vs `KIGI_MANAGED_CONFIG_FAIL_CLOSED`) /// is layered on top by [`resolve_fail_closed_mode`], not here. fn fail_closed_flag(requirements: &toml::Value) -> bool { requirements .get(FAIL_CLOSED_KEY) .and_then(|v| v.as_bool()) .unwrap_or(false) } /// Env override for [`FAIL_CLOSED_KEY`]. Named for prefix-alignment /// with `KIGI_MANAGED_CONFIG_URL`; only applies to `requirements.toml`. pub(crate) const FAIL_CLOSED_ENV: &str = "KIGI_MANAGED_CONFIG_FAIL_CLOSED"; /// Where a requirements layer came from: a file on disk, or the macOS MDM /// managed-preferences layer (admin-forced, no file). The typed split keeps a /// caller from `exists()`/reading a layer that has no path. #[derive(Debug, Clone)] pub enum RequirementsSource { File(PathBuf), Mdm, } impl RequirementsSource { /// Display/provenance label — a file path string, or the synthetic MDM source /// id (`ai.x.kigi:…`). For diagnostics and matching only; the MDM layer has no /// file, so this is a label (`Cow`), never a `Path` to open. pub fn label(&self) -> std::borrow::Cow<'_, str> { match self { Self::File(p) => p.to_string_lossy(), Self::Mdm => std::borrow::Cow::Borrowed(crate::macos_managed::MDM_REQUIREMENTS_SOURCE), } } } /// One requirements layer: the parsed TOML and where it came from. #[derive(Debug, Clone)] pub struct RequirementsLayer { pub value: toml::Value, pub source: RequirementsSource, /// `true` = root-owned system layer. Security decisions must trust this flag, /// not re-derive from the source (`KIGI_SHARE_DIR`-influenced, could carry `..`). pub is_system: bool, } /// All loaded requirements layers in apply order (user first, system last). /// Use when you need per-layer source attribution; otherwise use /// [`load_merged_requirements`]. pub fn requirements_layers() -> Vec { let mut out = Vec::new(); if let Some(user_path) = user_kigi_home().map(|g| g.join("requirements.toml")) && let Some(value) = load_requirements_layer(&user_path) { out.push(RequirementsLayer { value, source: RequirementsSource::File(user_path), is_system: false, }); } if let Some(dir) = system_config_dir() { let sys_path = dir.join("requirements.toml"); if let Some(value) = load_requirements_layer(&sys_path) { out.push(RequirementsLayer { value, source: RequirementsSource::File(sys_path), is_system: true, }); } } // macOS MDM: OS-protected admin layer (forced values only). Pushed last so it // wins the deep-merge over the system file and cloud cache; `is_system` so // security decisions trust it like the root-owned layer. if let Some(value) = mdm_requirements_value() { out.push(RequirementsLayer { value, source: RequirementsSource::Mdm, is_system: true, }); } out } /// User + system requirements deep-merged, system wins on conflict. /// Use for read-only consumers so user pins can't bypass system policy. pub fn load_merged_requirements() -> Option { let mut iter = requirements_layers().into_iter(); let mut merged = iter.next()?.value; for layer in iter { crate::loader::deep_merge_toml(&mut merged, &layer.value); } Some(merged) } pub(crate) fn load_requirements() -> Option { load_user_requirements(user_kigi_home().as_deref()) } /// User requirements layer from `/requirements.toml`, or `None` with no /// resolvable user home (rather than reading a cwd-relative `.kigi`). fn load_user_requirements(home: Option<&Path>) -> Option { load_requirements_layer(&home?.join("requirements.toml")) } pub(crate) fn load_system_requirements() -> Option { let dir = system_config_dir()?; load_requirements_layer(&dir.join("requirements.toml")) } /// Soft-fails on errors; fail-closed enforcement lives in /// [`validate_requirements`]. pub(crate) fn load_requirements_layer(path: &Path) -> Option { let v = match load_toml_file(path) { Ok(v) if v.as_table().is_some_and(|t| !t.is_empty()) => v, _ => return None, }; normalize_requirements_value(v, &path.display().to_string()) } /// Strip `fail_closed` and apply `[[version_overrides]]` for a parsed /// requirements layer (file or MDM), so every source is normalized identically. /// `None` (skip the layer) when version_overrides are invalid for this build. pub(crate) fn normalize_requirements_value( mut v: toml::Value, source: &str, ) -> Option { if let Some(table) = v.as_table_mut() { table.remove(FAIL_CLOSED_KEY); } if let Err(e) = apply_version_overrides_with_registered(&mut v) { tracing::error!( source = %source, error = %e, "requirements rejected: invalid version_overrides; admin policy NOT applied" ); return None; } Some(v) } /// The MDM requirements layer (read + normalized), or `None`. Shared so the /// enforced view and the effective-config view agree. pub(crate) fn mdm_requirements_value() -> Option { normalize_requirements_value( crate::macos_managed::managed_preferences_requirements()?, crate::macos_managed::MDM_REQUIREMENTS_SOURCE, ) } /// Errors from validating requirements layers at startup. #[derive(Debug, thiserror::Error)] pub enum RequirementsError { #[error( "requirements at {} has invalid version_overrides under fail_closed: {source}", path.display() )] InvalidVersionOverrides { path: PathBuf, #[source] source: VersionOverrideError, }, } /// `Ok(())` unless the layer opts into fail_closed AND has invalid /// `[[version_overrides]]` for the registered CLI version. /// /// Re-reads the file independently from [`load_requirements_layer`]: /// at startup both run, costing one extra small read per layer. Sharing /// the parse would couple loader+validator APIs for negligible gain. pub(crate) fn validate_requirements_layer(path: &Path) -> Result<(), RequirementsError> { let Ok(v) = load_toml_file(path) else { return Ok(()); }; validate_requirements_value(v, &RequirementsSource::File(path.to_path_buf())) } /// Fail-closed `[[version_overrides]]` validation for a parsed requirements layer /// (file or MDM). Reads `fail_closed` before applying overrides so a broken patch /// can't disable enforcement mid-load. `source` is the provenance label in the error. fn validate_requirements_value( mut v: toml::Value, source: &RequirementsSource, ) -> Result<(), RequirementsError> { if v.as_table().is_none_or(|t| t.is_empty()) { return Ok(()); } let fail_closed = resolve_fail_closed_mode(&v); let Ok(version) = kigi_version::installed_semver() else { return Ok(()); }; if let Err(e) = apply_version_overrides(&mut v, &version) && fail_closed { return Err(RequirementsError::InvalidVersionOverrides { path: PathBuf::from(source.label().as_ref()), source: e, }); } Ok(()) } /// Validates all requirements layers (user + system files, and macOS MDM). Call /// once at startup from the binary's `main()`; exit on `Err`. pub fn validate_requirements() -> Result<(), RequirementsError> { validate_user_requirements(user_kigi_home().as_deref())?; if let Some(dir) = system_config_dir() { validate_requirements_layer(&dir.join("requirements.toml"))?; } // MDM uses the raw value (fail_closed intact) so it's enforced like the files. if let Some(mdm) = crate::macos_managed::managed_preferences_requirements() { validate_requirements_value(mdm, &RequirementsSource::Mdm)?; } Ok(()) } /// Validate the user requirements layer if a user home resolves; otherwise a /// no-op (no cwd-relative `.kigi/requirements.toml` is read or enforced). fn validate_user_requirements(home: Option<&Path>) -> Result<(), RequirementsError> { match home { Some(g) => validate_requirements_layer(&g.join("requirements.toml")), None => Ok(()), } } /// `fail_closed` for [`validate_requirements`]'s version check: the admin file flag is authoritative; the env can only TIGHTEN it (force-on), never loosen. fn resolve_fail_closed_mode(requirements: &toml::Value) -> bool { fail_closed_flag(requirements) || env_bool(FAIL_CLOSED_ENV) == Some(true) } #[cfg(test)] mod tests { use super::*; /// Even with `fail_closed = true` in the file -- enforcement is /// `validate_requirements`, not the loader. #[test] fn load_requirements_layer_soft_fails_on_invalid_version_overrides() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-vo-soft-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("requirements.toml"); let mut f = std::fs::File::create(&path).unwrap(); writeln!( f, r#" fail_closed = true [[version_overrides]] minimum_version = "not-a-version" [version_overrides.features] telemetry = true "# ) .unwrap(); assert!(load_requirements_layer(&path).is_none()); let _ = std::fs::remove_file(&path); } #[test] fn validate_requirements_layer_errs_on_fail_closed_violation() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-vo-validate-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("requirements.toml"); let mut f = std::fs::File::create(&path).unwrap(); writeln!( f, r#" fail_closed = true [[version_overrides]] minimum_version = "not-a-version" "# ) .unwrap(); let err = validate_requirements_layer(&path).unwrap_err(); assert!(matches!( err, RequirementsError::InvalidVersionOverrides { .. } )); let _ = std::fs::remove_file(&path); } #[test] fn validate_requirements_layer_ok_without_fail_closed() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-vo-soft2-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("requirements.toml"); let mut f = std::fs::File::create(&path).unwrap(); writeln!( f, r#" [[version_overrides]] minimum_version = "not-a-version" "# ) .unwrap(); assert!(validate_requirements_layer(&path).is_ok()); let _ = std::fs::remove_file(&path); } #[test] fn fail_closed_env_can_tighten_but_not_loosen() { // SAFETY: process-global env mutation, restored before return. let off: toml::Value = toml::from_str("fail_closed = false\n").unwrap(); let on: toml::Value = toml::from_str("fail_closed = true\n").unwrap(); let prior = std::env::var(FAIL_CLOSED_ENV).ok(); // env=1 force-enables even when the file is off (tighten is allowed). unsafe { std::env::set_var(FAIL_CLOSED_ENV, "1") }; assert!(resolve_fail_closed_mode(&off)); assert!(resolve_fail_closed_mode(&on)); // env=0 must NOT disable an admin's fail_closed=true (no local bypass). unsafe { std::env::set_var(FAIL_CLOSED_ENV, "0") }; assert!( resolve_fail_closed_mode(&on), "a local env must not loosen admin fail_closed" ); assert!(!resolve_fail_closed_mode(&off)); // Unset → the admin file flag governs. unsafe { std::env::remove_var(FAIL_CLOSED_ENV) }; assert!(resolve_fail_closed_mode(&on)); assert!(!resolve_fail_closed_mode(&off)); unsafe { match prior { Some(p) => std::env::set_var(FAIL_CLOSED_ENV, p), None => std::env::remove_var(FAIL_CLOSED_ENV), } } } #[test] fn fail_closed_flag_from_str_reads_the_opt_in() { assert!(fail_closed_flag_from_str("fail_closed = true\n")); assert!(!fail_closed_flag_from_str("fail_closed = false\n")); // Missing key, a non-bool value, malformed TOML, and empty all read as // not-opted-in (best-effort false) rather than panicking. assert!(!fail_closed_flag_from_str("[features]\ntelemetry = true\n")); assert!(!fail_closed_flag_from_str("fail_closed = \"yes\"\n")); assert!(!fail_closed_flag_from_str("[unclosed")); assert!(!fail_closed_flag_from_str("")); } #[test] fn fail_closed_key_is_stripped_from_returned_layer() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-vo-strip-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("requirements.toml"); let mut f = std::fs::File::create(&path).unwrap(); writeln!(f, "fail_closed = true\n[features]\ntelemetry = true\n").unwrap(); let result = load_requirements_layer(&path).unwrap(); assert!( result.get(FAIL_CLOSED_KEY).is_none(), "fail_closed must not leak into the returned config" ); assert_eq!(result["features"]["telemetry"].as_bool(), Some(true)); let _ = std::fs::remove_file(&path); } #[test] fn load_user_requirements_is_none_without_user_home() { // No resolvable user home => no user requirements (no cwd-relative read). assert!(load_user_requirements(None).is_none()); } #[test] fn load_user_requirements_reads_layer_when_home_present() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-req-load-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap(); writeln!(f, "[features]\ntelemetry = true\n").unwrap(); let v = load_user_requirements(Some(&dir)).expect("layer present"); assert_eq!(v["features"]["telemetry"].as_bool(), Some(true)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn validate_user_requirements_ok_without_user_home() { // No user home => nothing to validate, no error. assert!(validate_user_requirements(None).is_ok()); } #[test] fn validate_user_requirements_errs_on_fail_closed_violation() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-req-validate-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut f = std::fs::File::create(dir.join("requirements.toml")).unwrap(); writeln!( f, r#" fail_closed = true [[version_overrides]] minimum_version = "not-a-version" "# ) .unwrap(); let err = validate_user_requirements(Some(&dir)).unwrap_err(); assert!(matches!( err, RequirementsError::InvalidVersionOverrides { .. } )); let _ = std::fs::remove_dir_all(&dir); } /// The macOS MDM layer enters at the value level (no file on disk): /// `mdm_requirements_value` / `validate_requirements` hand the CFPreferences /// value straight to these, so normalize (strip `fail_closed`, keep the /// clamp) and enforcement (Err under `fail_closed` + a bad override) must /// hold with no file in the loop. #[test] fn mdm_value_normalizes_and_enforces_like_a_file() { let source = crate::macos_managed::MDM_REQUIREMENTS_SOURCE; // Effective view: fail_closed stripped, the forced clamp kept. let raw: toml::Value = toml::from_str("fail_closed = true\n[features]\nweb_fetch = false\n").unwrap(); let normalized = normalize_requirements_value(raw, source).unwrap(); assert!(normalized.get(FAIL_CLOSED_KEY).is_none()); assert_eq!(normalized["features"]["web_fetch"].as_bool(), Some(false)); // Enforcement keeps fail_closed: a bad override under fail_closed => Err, // the same override without fail_closed soft-fails (Ok). let bad: toml::Value = toml::from_str( "fail_closed = true\n[[version_overrides]]\nminimum_version = \"not-a-version\"\n", ) .unwrap(); assert!(matches!( validate_requirements_value(bad, &RequirementsSource::Mdm).unwrap_err(), RequirementsError::InvalidVersionOverrides { .. } )); let soft: toml::Value = toml::from_str("[[version_overrides]]\nminimum_version = \"not-a-version\"\n").unwrap(); assert!(validate_requirements_value(soft, &RequirementsSource::Mdm).is_ok()); } }