car-context 0.56.1

Immutable authority/account/organization routing context for CAR work.
Documentation
//! Context shared by persisted agent definitions and credential routing.
//! This identifies work ownership; it is not proof of organization membership.
//!
//! [`resolve`] decides which context a new session binds: the user's default
//! org, a repository's org, an agent's permitted set and an explicit choice
//! combine under one rule that refuses rather than falls back.
use serde::{Deserialize, Serialize};

pub mod resolve;

/// Check an organization id's canonical form: 1-256 bytes of ASCII
/// alphanumerics, `.`, `_` or `-`. Shared by [`WorkContext::validate`] and by
/// places that name an org without an account, such as a repository's
/// `.car/config.toml`.
pub fn validate_organization_id(org: &str) -> Result<(), String> {
    if org.is_empty()
        || org.len() > 256
        || !org
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
    {
        return Err(format!(
            "`{org}` is not a canonical organization id (letters, digits, `.`, `_`, `-`)"
        ));
    }
    Ok(())
}

/// Immutable authority on an agent/resource. This is a routing key, not a
/// membership attestation. The token issuer validates each requested org.
#[derive(
    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(deny_unknown_fields)]
pub struct WorkContext {
    pub api_base: String,
    pub account_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub organization_id: Option<String>,
}

impl WorkContext {
    pub fn new(
        api_base: impl Into<String>,
        account_id: impl Into<String>,
        organization_id: Option<String>,
    ) -> Result<Self, String> {
        let context = Self {
            api_base: api_base.into().trim_end_matches('/').to_string(),
            account_id: account_id.into(),
            organization_id,
        };
        context.validate()?;
        Ok(context)
    }

    /// Stable filename-safe private resource key. Length framing prevents
    /// separator aliases; authority, account and organization all participate.
    pub fn storage_key(&self, resource: &str) -> String {
        use sha2::{Digest, Sha256};
        let mut hash = Sha256::new();
        hash.update(b"car-work-context-v1");
        for value in [
            self.api_base.as_str(),
            self.account_id.as_str(),
            self.organization_id.as_deref().unwrap_or(""),
            resource,
        ] {
            hash.update((value.len() as u64).to_be_bytes());
            hash.update(value.as_bytes());
        }
        format!("work-{:x}", hash.finalize())
    }

    pub fn validate(&self) -> Result<(), String> {
        let url = url::Url::parse(&self.api_base).map_err(|_| "invalid credential authority")?;
        let loopback = url.host_str().is_some_and(|host| {
            host == "localhost"
                || host
                    .parse::<std::net::IpAddr>()
                    .is_ok_and(|ip| ip.is_loopback())
        });
        if (url.scheme() != "https" && !(url.scheme() == "http" && loopback))
            || !url.username().is_empty()
            || url.password().is_some()
            || url.query().is_some()
            || url.fragment().is_some()
            || self.api_base.ends_with('/')
            || self.account_id.trim() != self.account_id
            || self.account_id.is_empty()
            || self.account_id.len() > 256
        {
            return Err("credential context requires a canonical authority and account".into());
        }
        if self
            .organization_id
            .as_deref()
            .is_some_and(|org| validate_organization_id(org).is_err())
        {
            return Err("credential context requires a canonical organization id".into());
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn private_resource_keys_keep_all_ownership_dimensions_separate() {
        let base =
            WorkContext::new("https://api.parslee.ai", "matt", Some("parslee".into())).unwrap();
        let key = base.storage_key("agent:same-id");
        let mut contexts = Vec::new();
        let mut other = base.clone();
        other.organization_id = Some("flyexclusive".into());
        contexts.push(other);
        let mut other = base.clone();
        other.organization_id = None;
        contexts.push(other);
        let mut other = base.clone();
        other.account_id = "colleague".into();
        contexts.push(other);
        let mut other = base.clone();
        other.api_base = "https://other.example".into();
        contexts.push(other);
        for other in contexts {
            assert_ne!(other.storage_key("agent:same-id"), key);
        }
        assert_ne!(base.storage_key("agent:other-id"), key);
        assert_eq!(
            serde_json::from_str::<WorkContext>(&serde_json::to_string(&base).unwrap())
                .unwrap()
                .storage_key("agent:same-id"),
            key
        );
    }
}