apcore_toolkit/
resolve_target.rs1use std::sync::LazyLock;
9
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ResolvedTarget {
17 pub module_path: String,
19 pub qualname: String,
21}
22
23#[derive(Debug, Error)]
53pub enum ResolveTargetError {
54 #[error("Invalid target format: \"{target}\". Expected \"module_path:qualname\".")]
55 MissingSeparator { target: String },
56
57 #[error("Invalid target format: \"{target}\". Module path is empty.")]
58 EmptyModulePath { target: String },
59
60 #[error("Invalid target format: \"{target}\". Qualified name is empty.")]
61 EmptyQualname { target: String },
62
63 #[error("Invalid qualname \"{qualname}\" in target \"{target}\". Must be a valid identifier.")]
64 InvalidQualname { target: String, qualname: String },
65
66 #[error("Invalid module path \"{module_path}\" in target \"{target}\": {reason}.")]
67 InvalidModulePath {
68 target: String,
69 module_path: String,
70 reason: String,
71 },
72}
73
74const MAX_MODULE_PATH_LEN: usize = 512;
76
77static IDENT_RE: LazyLock<Regex> =
79 LazyLock::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("static regex"));
80
81fn validate_module_path(module_path: &str, target: &str) -> Result<(), ResolveTargetError> {
83 if module_path.len() > MAX_MODULE_PATH_LEN {
84 return Err(ResolveTargetError::InvalidModulePath {
85 target: target.to_string(),
86 module_path: module_path.to_string(),
87 reason: format!("exceeds maximum length of {MAX_MODULE_PATH_LEN}"),
88 });
89 }
90 if module_path
91 .bytes()
92 .any(|b| b == 0 || (b < 0x20 && b != b'\t'))
93 {
94 return Err(ResolveTargetError::InvalidModulePath {
95 target: target.to_string(),
96 module_path: module_path.to_string(),
97 reason: "contains invalid control characters".to_string(),
98 });
99 }
100 if module_path.contains("..") {
101 return Err(ResolveTargetError::InvalidModulePath {
102 target: target.to_string(),
103 module_path: module_path.to_string(),
104 reason: "contains '..' (path traversal)".to_string(),
105 });
106 }
107 Ok(())
108}
109
110pub fn resolve_target(target: &str) -> Result<ResolvedTarget, ResolveTargetError> {
111 let last_colon = target
112 .rfind(':')
113 .ok_or_else(|| ResolveTargetError::MissingSeparator {
114 target: target.to_string(),
115 })?;
116
117 let module_path = &target[..last_colon];
118 let qualname = &target[last_colon + 1..];
119
120 if module_path.is_empty() {
121 return Err(ResolveTargetError::EmptyModulePath {
122 target: target.to_string(),
123 });
124 }
125
126 if qualname.is_empty() {
127 return Err(ResolveTargetError::EmptyQualname {
128 target: target.to_string(),
129 });
130 }
131
132 validate_module_path(module_path, target)?;
133
134 if !IDENT_RE.is_match(qualname) {
135 return Err(ResolveTargetError::InvalidQualname {
136 target: target.to_string(),
137 qualname: qualname.to_string(),
138 });
139 }
140
141 Ok(ResolvedTarget {
142 module_path: module_path.to_string(),
143 qualname: qualname.to_string(),
144 })
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_resolve_target_python_style() {
153 let result = resolve_target("my_package.my_module:MyClass").unwrap();
154 assert_eq!(result.module_path, "my_package.my_module");
155 assert_eq!(result.qualname, "MyClass");
156 }
157
158 #[test]
159 fn test_resolve_target_rust_style() {
160 let result = resolve_target("my_crate::handlers::task:create_task").unwrap();
161 assert_eq!(result.module_path, "my_crate::handlers::task");
162 assert_eq!(result.qualname, "create_task");
163 }
164
165 #[test]
166 fn test_resolve_target_simple() {
167 let result = resolve_target("app:handler").unwrap();
168 assert_eq!(result.module_path, "app");
169 assert_eq!(result.qualname, "handler");
170 }
171
172 #[test]
173 fn test_resolve_target_typescript_style() {
174 let result = resolve_target("./handlers/task:createTask").unwrap();
175 assert_eq!(result.module_path, "./handlers/task");
176 assert_eq!(result.qualname, "createTask");
177 }
178
179 #[test]
180 fn test_resolve_target_no_colon() {
181 let result = resolve_target("no_colon_here");
182 assert!(result.is_err());
183 assert!(result
184 .unwrap_err()
185 .to_string()
186 .contains("Invalid target format"));
187 }
188
189 #[test]
190 fn test_resolve_target_empty_module() {
191 let result = resolve_target(":qualname");
192 assert!(result.is_err());
193 assert!(result
194 .unwrap_err()
195 .to_string()
196 .contains("Module path is empty"));
197 }
198
199 #[test]
200 fn test_resolve_target_empty_qualname() {
201 let result = resolve_target("module:");
202 assert!(result.is_err());
203 assert!(result
204 .unwrap_err()
205 .to_string()
206 .contains("Qualified name is empty"));
207 }
208
209 #[test]
210 fn test_resolve_target_invalid_qualname() {
211 let result = resolve_target("module:123invalid");
212 assert!(result.is_err());
213 assert!(result
214 .unwrap_err()
215 .to_string()
216 .contains("Must be a valid identifier"));
217 }
218
219 #[test]
220 fn test_resolve_target_module_path_with_null_byte() {
221 let result = resolve_target("mod\x00path:func");
222 assert!(result.is_err());
223 let msg = result.unwrap_err().to_string();
224 assert!(msg.contains("control characters"), "{msg}");
225 }
226
227 #[test]
228 fn test_resolve_target_module_path_too_long() {
229 let long_path = "a".repeat(513);
230 let result = resolve_target(&format!("{long_path}:func"));
231 assert!(result.is_err());
232 let msg = result.unwrap_err().to_string();
233 assert!(msg.contains("maximum length"), "{msg}");
234 }
235
236 #[test]
237 fn test_resolve_target_module_path_with_dotdot() {
238 let result = resolve_target("../../../etc/passwd:func");
239 assert!(result.is_err());
240 let msg = result.unwrap_err().to_string();
241 assert!(msg.contains("path traversal"), "{msg}");
242 }
243
244 #[test]
245 fn test_resolve_target_module_path_dotdot_in_middle() {
246 let result = resolve_target("a/b/../c:func");
247 assert!(result.is_err());
248 }
249
250 #[test]
251 fn test_resolve_target_qualname_with_spaces() {
252 let result = resolve_target("module:has spaces");
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn test_resolve_target_node_prefix() {
258 let result = resolve_target("node:path:join").unwrap();
260 assert_eq!(result.module_path, "node:path");
261 assert_eq!(result.qualname, "join");
262 }
263
264 #[test]
265 fn test_resolve_target_underscore_qualname() {
266 let result = resolve_target("mod:_private_func").unwrap();
267 assert_eq!(result.qualname, "_private_func");
268 }
269
270 #[test]
271 fn test_resolved_target_serde_roundtrip() {
272 let target = ResolvedTarget {
273 module_path: "my_crate::handlers".into(),
274 qualname: "create_task".into(),
275 };
276 let json = serde_json::to_string(&target).unwrap();
277 let deserialized: ResolvedTarget = serde_json::from_str(&json).unwrap();
278 assert_eq!(deserialized, target);
279 }
280
281 #[test]
282 fn test_ident_re_not_recompiled_per_call() {
283 let r1 = resolve_target("a:valid_func").unwrap();
285 let r2 = resolve_target("b:another_func").unwrap();
286 assert_eq!(r1.qualname, "valid_func");
287 assert_eq!(r2.qualname, "another_func");
288 }
289}