Skip to main content

remem/cursor_hook/
identity.rs

1//! Cursor event identity and workspace-root validation (B-002, B-013).
2//!
3//! Every helper takes the already-parsed outer JSON object and returns
4//! fail-closed errors whose messages contain only field names/types and a
5//! correlation id, never raw payload content.
6
7use anyhow::{anyhow, Result};
8use serde_json::Value;
9
10use super::correlation_id;
11
12/// Validated canonical event identity: `session_id` with event-local
13/// `session_id == conversation_id` equality (when both are present).
14/// Subagent events carry their own internally-equal identity and are accepted
15/// as distinct sessions; no parent-child coercion happens here.
16pub fn validate_identity(object: &serde_json::Map<String, Value>) -> Result<String> {
17    let session_id = required_non_empty_string(object, "session_id")?;
18    match object.get("conversation_id") {
19        None => {}
20        Some(Value::String(conversation_id)) => {
21            if conversation_id.trim().is_empty() {
22                return Err(field_error("conversation_id", "empty string"));
23            }
24            if conversation_id != &session_id {
25                return Err(anyhow!(
26                    "cursor hook payload violates event-local identity equality \
27                     (session_id != conversation_id) [correlation_id={}]",
28                    correlation_id()
29                ));
30            }
31        }
32        Some(_) => return Err(field_error("conversation_id", "wrong type")),
33    }
34    Ok(session_id)
35}
36
37/// Like [`validate_identity`] but requires `conversation_id` to be present
38/// (observe/stop contract: the required non-empty `conversation_id` maps to
39/// the canonical `session_id`).
40pub fn validate_identity_with_required_conversation(
41    object: &serde_json::Map<String, Value>,
42) -> Result<String> {
43    if !object.contains_key("conversation_id") {
44        return Err(field_error("conversation_id", "missing"));
45    }
46    validate_identity(object)
47}
48
49/// Validates `workspace_roots` as an array whose total length is exactly one
50/// and whose sole string is non-empty after trimming, then normalizes it via
51/// the platform-aware normalizer. `[]`, `[""]`, mixed blank arrays, and
52/// multi-root arrays all fail closed; blank entries are never filtered before
53/// the length check (B-013).
54pub fn validate_workspace_root(object: &serde_json::Map<String, Value>) -> Result<String> {
55    let Some(value) = object.get("workspace_roots") else {
56        return Err(field_error("workspace_roots", "missing"));
57    };
58    let Value::Array(roots) = value else {
59        return Err(field_error("workspace_roots", "wrong type"));
60    };
61    if roots.len() != 1 {
62        return Err(anyhow!(
63            "cursor hook payload field 'workspace_roots' must contain exactly one root, \
64             got {} [correlation_id={}]",
65            roots.len(),
66            correlation_id()
67        ));
68    }
69    let Value::String(root) = &roots[0] else {
70        return Err(field_error("workspace_roots[0]", "wrong type"));
71    };
72    let trimmed = root.trim();
73    if trimmed.is_empty() {
74        return Err(field_error("workspace_roots[0]", "empty string"));
75    }
76    normalize_workspace_root(trimmed)
77}
78
79/// Platform-aware workspace-root normalizer. Only shapes backed by sanitized
80/// #822/PR #914 real-host evidence are accepted: absolute Unix paths observed
81/// on macOS. Windows drive forms (`/c:/...`, `C:\...`), UNC paths, and
82/// relative paths are unverified and fail closed; the raw string is never
83/// persisted as project identity (B-002, R5).
84pub fn normalize_workspace_root(trimmed: &str) -> Result<String> {
85    let unverified = |shape: &str| {
86        anyhow!(
87            "cursor workspace root has an unverified platform shape ({shape}); \
88             failing closed without persisting raw path identity [correlation_id={}]",
89            correlation_id()
90        )
91    };
92    if trimmed.contains('\\') {
93        return Err(unverified("backslash path"));
94    }
95    if !trimmed.starts_with('/') {
96        return Err(unverified("relative or drive-letter path"));
97    }
98    if trimmed.starts_with("//") {
99        return Err(unverified("UNC-like path"));
100    }
101    let mut chars = trimmed.chars();
102    let _slash = chars.next();
103    if let (Some(first), Some(second)) = (chars.next(), chars.next()) {
104        if first.is_ascii_alphabetic() && second == ':' {
105            return Err(unverified("windows drive path"));
106        }
107    }
108    Ok(trimmed.to_string())
109}
110
111/// Validates the null-tolerant `transcript_path` base field: missing or
112/// `null` maps to `None`, a non-empty string maps to `Some`. Empty strings
113/// and other types fail closed. A null child path is never replaced with a
114/// parent path here or anywhere downstream.
115pub fn validate_transcript_path(object: &serde_json::Map<String, Value>) -> Result<Option<String>> {
116    match object.get("transcript_path") {
117        None | Some(Value::Null) => Ok(None),
118        Some(Value::String(path)) => {
119            if path.trim().is_empty() {
120                Err(field_error("transcript_path", "empty string"))
121            } else {
122                Ok(Some(path.clone()))
123            }
124        }
125        Some(_) => Err(field_error("transcript_path", "wrong type")),
126    }
127}
128
129pub(super) fn required_non_empty_string(
130    object: &serde_json::Map<String, Value>,
131    field: &str,
132) -> Result<String> {
133    match object.get(field) {
134        None => Err(field_error(field, "missing")),
135        Some(Value::String(value)) => {
136            if value.trim().is_empty() {
137                Err(field_error(field, "empty string"))
138            } else {
139                Ok(value.clone())
140            }
141        }
142        Some(_) => Err(field_error(field, "wrong type")),
143    }
144}
145
146pub(super) fn field_error(field: &str, problem: &str) -> anyhow::Error {
147    anyhow!(
148        "cursor hook payload field '{field}' invalid: {problem} [correlation_id={}]",
149        correlation_id()
150    )
151}