remem/cursor_hook/
identity.rs1use anyhow::{anyhow, Result};
8use serde_json::Value;
9
10use super::correlation_id;
11
12pub 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
37pub 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
49pub 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
79pub 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
111pub 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}