bijux-dag-app 0.4.1

Application orchestration and response-shaping layer for Bijux DAG command surfaces.
Documentation
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;

fn default_container_image_reference_policy() -> bijux_dag_runtime::ContainerImageReferencePolicy {
    bijux_dag_runtime::ContainerImageReferencePolicy::RequireDigest
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CacheModeSurface {
    Off,
    Read,
    ReadWrite,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MaterializeInputsSurface {
    None,
    Direct,
    All,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PolicySurfaceConfig {
    pub deny_network: bool,
    pub deny_env: bool,
    pub deny_clock: bool,
    pub clean_env: bool,
    #[serde(default = "default_container_image_reference_policy")]
    pub container_image_reference_policy: bijux_dag_runtime::ContainerImageReferencePolicy,
    #[serde(default)]
    pub allowed_env: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSurfaceConfig {
    pub jobs: usize,
    pub cache_mode: CacheModeSurface,
    pub materialize_inputs: MaterializeInputsSurface,
    pub policy: PolicySurfaceConfig,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PartialRuntimeSurfaceConfig {
    pub jobs: Option<usize>,
    pub cache_mode: Option<CacheModeSurface>,
    pub materialize_inputs: Option<MaterializeInputsSurface>,
    pub policy: Option<PolicySurfaceConfig>,
}

pub fn default_runtime_config() -> RuntimeSurfaceConfig {
    RuntimeSurfaceConfig {
        jobs: 1,
        cache_mode: CacheModeSurface::Off,
        materialize_inputs: MaterializeInputsSurface::None,
        policy: PolicySurfaceConfig {
            deny_network: false,
            deny_env: false,
            deny_clock: false,
            clean_env: true,
            container_image_reference_policy:
                bijux_dag_runtime::ContainerImageReferencePolicy::RequireDigest,
            allowed_env: Vec::new(),
        },
    }
}

pub fn resolve_effective_config(
    cli: PartialRuntimeSurfaceConfig,
    explicit: Option<PartialRuntimeSurfaceConfig>,
    env_cfg: Option<PartialRuntimeSurfaceConfig>,
    defaults: RuntimeSurfaceConfig,
) -> RuntimeSurfaceConfig {
    let mut effective = defaults;
    if let Some(env_cfg) = env_cfg {
        apply_partial(&mut effective, env_cfg);
    }
    if let Some(explicit) = explicit {
        apply_partial(&mut effective, explicit);
    }
    apply_partial(&mut effective, cli);
    normalize_runtime_config(effective)
}

pub fn normalize_runtime_config(mut cfg: RuntimeSurfaceConfig) -> RuntimeSurfaceConfig {
    let mut env_keys = BTreeSet::new();
    for key in cfg.policy.allowed_env.drain(..) {
        let normalized = key.trim().to_ascii_uppercase();
        if !normalized.is_empty() {
            env_keys.insert(normalized);
        }
    }
    cfg.policy.allowed_env = env_keys.into_iter().collect();
    cfg
}

pub fn config_fingerprint(cfg: &RuntimeSurfaceConfig) -> String {
    let normalized = normalize_runtime_config(cfg.clone());
    let bytes = serde_json::to_vec(&normalized).expect("config serialize");
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    format!("{:x}", hasher.finalize())
}

pub fn policy_evaluation_trace(policy: &PolicySurfaceConfig) -> Vec<String> {
    let mut events = Vec::new();
    events.push(format!(
        "rule:deny_network decision:{}",
        if policy.deny_network { "deny" } else { "allow" }
    ));
    events
        .push(format!("rule:deny_env decision:{}", if policy.deny_env { "deny" } else { "allow" }));
    events.push(format!(
        "rule:deny_clock decision:{}",
        if policy.deny_clock { "deny" } else { "allow" }
    ));
    events.push(format!(
        "rule:clean_env decision:{}",
        if policy.clean_env { "enforce" } else { "skip" }
    ));
    events.push(format!(
        "rule:container_image_reference decision:{}",
        match policy.container_image_reference_policy {
            bijux_dag_runtime::ContainerImageReferencePolicy::RequireDigest => "require_digest",
            bijux_dag_runtime::ContainerImageReferencePolicy::AllowUnpinned => "allow_unpinned",
        }
    ));
    events
}

fn apply_partial(target: &mut RuntimeSurfaceConfig, partial: PartialRuntimeSurfaceConfig) {
    if let Some(jobs) = partial.jobs {
        target.jobs = jobs;
    }
    if let Some(cache_mode) = partial.cache_mode {
        target.cache_mode = cache_mode;
    }
    if let Some(materialize_inputs) = partial.materialize_inputs {
        target.materialize_inputs = materialize_inputs;
    }
    if let Some(policy) = partial.policy {
        target.policy = policy;
    }
}

#[cfg(test)]
mod tests {
    use super::{
        config_fingerprint, default_runtime_config, normalize_runtime_config,
        policy_evaluation_trace, resolve_effective_config, CacheModeSurface,
        MaterializeInputsSurface, PartialRuntimeSurfaceConfig, PolicySurfaceConfig,
    };

    #[test]
    fn default_runtime_config_is_stable() {
        let cfg = default_runtime_config();
        assert_eq!(cfg.jobs, 1);
        assert_eq!(cfg.cache_mode, CacheModeSurface::Off);
        assert_eq!(cfg.materialize_inputs, MaterializeInputsSurface::None);
        assert!(cfg.policy.clean_env);
    }

    #[test]
    fn normalize_runtime_config_deduplicates_and_uppercases_allowed_env() {
        let mut cfg = default_runtime_config();
        cfg.policy.allowed_env = vec![" path ".into(), "PATH".into(), "home".into()];
        let normalized = normalize_runtime_config(cfg);
        assert_eq!(normalized.policy.allowed_env, vec!["HOME", "PATH"]);
    }

    #[test]
    fn resolve_effective_config_respects_precedence() {
        let defaults = default_runtime_config();
        let env_cfg = Some(PartialRuntimeSurfaceConfig {
            jobs: Some(2),
            ..PartialRuntimeSurfaceConfig::default()
        });
        let explicit = Some(PartialRuntimeSurfaceConfig {
            jobs: Some(3),
            cache_mode: Some(CacheModeSurface::Read),
            ..PartialRuntimeSurfaceConfig::default()
        });
        let cli = PartialRuntimeSurfaceConfig {
            jobs: Some(4),
            materialize_inputs: Some(MaterializeInputsSurface::Direct),
            ..PartialRuntimeSurfaceConfig::default()
        };
        let effective = resolve_effective_config(cli, explicit, env_cfg, defaults);
        assert_eq!(effective.jobs, 4);
        assert_eq!(effective.cache_mode, CacheModeSurface::Read);
        assert_eq!(effective.materialize_inputs, MaterializeInputsSurface::Direct);
    }

    #[test]
    fn config_fingerprint_changes_on_semantic_change() {
        let mut a = default_runtime_config();
        let mut b = default_runtime_config();
        b.policy = PolicySurfaceConfig {
            deny_network: true,
            deny_env: false,
            deny_clock: false,
            clean_env: true,
            container_image_reference_policy:
                bijux_dag_runtime::ContainerImageReferencePolicy::RequireDigest,
            allowed_env: vec![],
        };
        assert_ne!(config_fingerprint(&a), config_fingerprint(&b));
        a.policy.deny_network = true;
        assert_eq!(config_fingerprint(&a), config_fingerprint(&b));
    }

    #[test]
    fn policy_evaluation_trace_contains_expected_rules() {
        let policy = PolicySurfaceConfig {
            deny_network: true,
            deny_env: true,
            deny_clock: false,
            clean_env: true,
            container_image_reference_policy:
                bijux_dag_runtime::ContainerImageReferencePolicy::RequireDigest,
            allowed_env: vec![],
        };
        let trace = policy_evaluation_trace(&policy);
        assert!(trace.iter().any(|line| line.contains("rule:deny_network")));
        assert!(trace.iter().any(|line| line.contains("rule:deny_env")));
        assert!(trace.iter().any(|line| line.contains("rule:clean_env")));
    }
}