chio-http-core 0.1.2

Protocol-agnostic HTTP security types for the Chio kernel
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::authority::{
    HttpAuthorityInput, HttpAuthorityPolicy, HTTP_AUTHORITY_SERVER_ID, HTTP_AUTHORITY_TOOL_NAME,
};
use crate::HttpMethod;

pub(crate) const MALFORMED_CHIO_TOOLS_PATH_REASON: &str = "malformed /chio/tools path identity";

pub(crate) struct CapabilityBinding {
    pub(crate) requested_tool_server: Option<String>,
    pub(crate) requested_tool_name: Option<String>,
    pub(crate) requested_arguments: Option<Value>,
    pub(crate) invalid_reason: Option<String>,
    pub(crate) policy: HttpAuthorityPolicy,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HttpKernelAuthorizationRequest {
    pub(crate) method: HttpMethod,
    pub(crate) route_pattern: String,
    pub(crate) path: String,
    pub(crate) content_hash: String,
    pub(crate) caller_identity_hash: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) session_id: Option<String>,
    pub(crate) policy: HttpAuthorityPolicy,
    pub(crate) capability: HttpKernelCapabilityState,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HttpKernelCapabilityState {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) invalid_reason: Option<String>,
}

enum ChioToolsPathIdentity {
    NotToolsPath,
    Malformed,
    Identity {
        server_id: String,
        tool_name: String,
    },
}

pub(crate) fn capability_binding(
    input: &HttpAuthorityInput<'_>,
    caller_identity_hash: &str,
) -> CapabilityBinding {
    match chio_tools_path_identity(input.path) {
        ChioToolsPathIdentity::Identity {
            server_id,
            tool_name,
        } => {
            return CapabilityBinding {
                requested_tool_server: Some(server_id),
                requested_tool_name: Some(tool_name),
                requested_arguments: input.requested_arguments.cloned(),
                invalid_reason: None,
                policy: HttpAuthorityPolicy::DenyByDefault,
            };
        }
        ChioToolsPathIdentity::Malformed => {
            return CapabilityBinding {
                requested_tool_server: None,
                requested_tool_name: None,
                requested_arguments: input.requested_arguments.cloned(),
                invalid_reason: Some(MALFORMED_CHIO_TOOLS_PATH_REASON.to_string()),
                policy: HttpAuthorityPolicy::DenyByDefault,
            };
        }
        ChioToolsPathIdentity::NotToolsPath => {}
    }

    if input.policy != HttpAuthorityPolicy::DenyByDefault {
        return request_field_capability_binding(input);
    }

    http_authority_capability_binding(input, caller_identity_hash)
}

fn request_field_capability_binding(input: &HttpAuthorityInput<'_>) -> CapabilityBinding {
    CapabilityBinding {
        requested_tool_server: input.requested_tool_server.map(str::to_owned),
        requested_tool_name: input.requested_tool_name.map(str::to_owned),
        requested_arguments: input.requested_arguments.cloned(),
        invalid_reason: None,
        policy: input.policy,
    }
}

fn http_authority_capability_binding(
    input: &HttpAuthorityInput<'_>,
    caller_identity_hash: &str,
) -> CapabilityBinding {
    let arguments = serde_json::to_value(HttpKernelAuthorizationRequest {
        method: input.method,
        route_pattern: input.route_pattern.clone(),
        path: input.path.to_string(),
        content_hash: input.body_hash.clone().unwrap_or_default(),
        caller_identity_hash: caller_identity_hash.to_string(),
        session_id: input.session_id.clone(),
        policy: input.policy,
        capability: HttpKernelCapabilityState {
            id: None,
            invalid_reason: None,
        },
    })
    .unwrap_or(Value::Null);

    CapabilityBinding {
        requested_tool_server: Some(HTTP_AUTHORITY_SERVER_ID.to_string()),
        requested_tool_name: Some(HTTP_AUTHORITY_TOOL_NAME.to_string()),
        requested_arguments: Some(arguments),
        invalid_reason: None,
        policy: input.policy,
    }
}

fn chio_tools_path_identity(path: &str) -> ChioToolsPathIdentity {
    let Some(rest) = path.strip_prefix("/chio/tools/") else {
        return ChioToolsPathIdentity::NotToolsPath;
    };
    let Some((server_id, tool_name)) = rest.split_once('/') else {
        return ChioToolsPathIdentity::Malformed;
    };
    if server_id.is_empty() || tool_name.is_empty() || tool_name.contains('/') {
        return ChioToolsPathIdentity::Malformed;
    }
    let Some(server_id) = decode_path_identity_segment(server_id) else {
        return ChioToolsPathIdentity::Malformed;
    };
    let Some(tool_name) = decode_path_identity_segment(tool_name) else {
        return ChioToolsPathIdentity::Malformed;
    };
    ChioToolsPathIdentity::Identity {
        server_id,
        tool_name,
    }
}

fn decode_path_identity_segment(segment: &str) -> Option<String> {
    let mut decoded = Vec::with_capacity(segment.len());
    let bytes = segment.as_bytes();
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' {
            let high = hex_value(*bytes.get(index + 1)?)?;
            let low = hex_value(*bytes.get(index + 2)?)?;
            decoded.push((high << 4) | low);
            index += 3;
        } else {
            decoded.push(bytes[index]);
            index += 1;
        }
    }
    let decoded = String::from_utf8(decoded).ok()?;
    if decoded.is_empty() {
        return None;
    }
    Some(decoded)
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn chio_tools_path_identity_parses_reserved_prefix() {
        let ChioToolsPathIdentity::Identity {
            server_id,
            tool_name,
        } = chio_tools_path_identity("/chio/tools/matrix/files.read")
        else {
            panic!("expected identity");
        };
        assert_eq!(server_id, "matrix");
        assert_eq!(tool_name, "files.read");
    }

    #[test]
    fn chio_tools_path_identity_rejects_missing_tool_segment() {
        assert!(matches!(
            chio_tools_path_identity("/chio/tools/matrix"),
            ChioToolsPathIdentity::Malformed
        ));
    }

    #[test]
    fn chio_tools_path_identity_requires_trailing_slash_prefix() {
        assert!(matches!(
            chio_tools_path_identity("/chio/tools"),
            ChioToolsPathIdentity::NotToolsPath
        ));
    }

    #[test]
    fn chio_tools_path_identity_rejects_nested_tool_segments() {
        assert!(matches!(
            chio_tools_path_identity("/chio/tools/matrix/files/read"),
            ChioToolsPathIdentity::Malformed
        ));
    }

    #[test]
    fn chio_tools_path_identity_rejects_incomplete_percent_encoding() {
        assert!(decode_path_identity_segment("terminal%2").is_none());
        assert!(matches!(
            chio_tools_path_identity("/chio/tools/acp/terminal%2"),
            ChioToolsPathIdentity::Malformed
        ));
    }

    #[test]
    fn chio_tools_path_identity_rejects_non_utf8_percent_encoding() {
        assert!(decode_path_identity_segment("terminal%FFcreate").is_none());
        assert!(matches!(
            chio_tools_path_identity("/chio/tools/acp/terminal%FFcreate"),
            ChioToolsPathIdentity::Malformed
        ));
    }

    #[test]
    fn chio_tools_path_identity_non_tools_path_is_not_tools_path() {
        assert!(matches!(
            chio_tools_path_identity("/pets/42"),
            ChioToolsPathIdentity::NotToolsPath
        ));
    }
}