1use serde_json::Value;
2use std::path::{Component, Path, PathBuf};
3
4pub async fn handle_core_capability(
5 capability: &str,
6 action: &str,
7 data: &Value,
8 workspace_root: Option<&Path>,
9) -> Result<Value, String> {
10 match capability {
11 "core.files" => handle_files(action, data, workspace_root).await,
12 "core.execution" => handle_execution(action, data).await,
13 _ => Err(format!("Unknown core capability: {}", capability)),
14 }
15}
16
17fn normalize_under_root(root: &Path, path_str: &str) -> Result<PathBuf, String> {
18 if path_str.contains("..") {
19 return Err("Path traversal is not allowed outside the configured workspace".into());
20 }
21
22 let candidate = if Path::new(path_str).is_absolute() {
23 PathBuf::from(path_str)
24 } else {
25 root.join(path_str)
26 };
27
28 let mut normalized = PathBuf::new();
29 for component in candidate.components() {
30 match component {
31 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
32 Component::RootDir => normalized.push(component.as_os_str()),
33 Component::CurDir => {}
34 Component::Normal(part) => normalized.push(part),
35 Component::ParentDir => {
36 if !normalized.pop() {
37 return Err(
38 "Path traversal is not allowed outside the configured workspace".into(),
39 );
40 }
41 }
42 }
43 }
44
45 let root_canon = std::fs::canonicalize(root).map_err(|e| {
46 format!(
47 "Failed to resolve workspace root '{}': {}",
48 root.display(),
49 e
50 )
51 })?;
52
53 if normalized.exists() {
54 let candidate_canon = std::fs::canonicalize(&normalized)
55 .map_err(|e| format!("Failed to resolve path '{}': {}", normalized.display(), e))?;
56 if !candidate_canon.starts_with(&root_canon) {
57 return Err("Path is outside the configured workspace".into());
58 }
59 Ok(candidate_canon)
60 } else {
61 let parent = normalized.parent().unwrap_or(root);
62 let parent_canon = std::fs::canonicalize(parent).map_err(|e| {
63 format!(
64 "Failed to resolve parent path '{}': {}",
65 parent.display(),
66 e
67 )
68 })?;
69 if !parent_canon.starts_with(&root_canon) {
70 return Err("Path is outside the configured workspace".into());
71 }
72 Ok(normalized)
73 }
74}
75
76fn resolve_file_path(workspace_root: Option<&Path>, path_str: &str) -> Result<PathBuf, String> {
77 match workspace_root {
78 Some(root) => normalize_under_root(root, path_str),
79 None => Ok(PathBuf::from(path_str)),
80 }
81}
82
83async fn handle_files(
84 action: &str,
85 data: &Value,
86 workspace_root: Option<&Path>,
87) -> Result<Value, String> {
88 match action {
89 "list" => {
90 let path_str = data["path"].as_str().unwrap_or(".");
91 let path_buf = resolve_file_path(workspace_root, path_str)?;
92 let path = path_buf.as_path();
93 if !path.exists() {
94 return Err(format!("Path does not exist: {}", path_str));
95 }
96 let entries: Vec<Value> = match std::fs::read_dir(path) {
97 Ok(reader) => reader
98 .filter_map(|e| e.ok())
99 .map(|entry| {
100 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
101 serde_json::json!({
102 "name": entry.file_name().to_string_lossy(),
103 "is_dir": is_dir,
104 })
105 })
106 .collect(),
107 Err(e) => return Err(format!("Failed to list directory: {}", e)),
108 };
109 Ok(serde_json::json!({ "entries": entries }))
110 }
111 "read" => {
112 let path_str = data["path"]
113 .as_str()
114 .ok_or_else(|| "Missing 'path' in data".to_string())?;
115 let path_buf = resolve_file_path(workspace_root, path_str)?;
116 match std::fs::read_to_string(&path_buf) {
117 Ok(content) => Ok(serde_json::json!({ "content": content })),
118 Err(e) => Err(format!("Failed to read file: {}", e)),
119 }
120 }
121 "write" => {
122 let path_str = data["path"]
123 .as_str()
124 .ok_or_else(|| "Missing 'path' in data".to_string())?;
125 let content = data["content"]
126 .as_str()
127 .ok_or_else(|| "Missing 'content' in data".to_string())?;
128 let path_buf = resolve_file_path(workspace_root, path_str)?;
129 if let Some(parent) = path_buf.parent() {
130 std::fs::create_dir_all(parent)
131 .map_err(|e| format!("Failed to create parent directory: {}", e))?;
132 }
133 match std::fs::write(&path_buf, content) {
134 Ok(()) => Ok(serde_json::json!({ "written": true, "path": path_str })),
135 Err(e) => Err(format!("Failed to write file: {}", e)),
136 }
137 }
138 "delete" => {
139 let path_str = data["path"]
140 .as_str()
141 .ok_or_else(|| "Missing 'path' in data".to_string())?;
142 let path_buf = resolve_file_path(workspace_root, path_str)?;
143 let path = path_buf.as_path();
144 let result = if path.is_dir() {
145 std::fs::remove_dir_all(path)
146 } else {
147 std::fs::remove_file(path)
148 };
149 match result {
150 Ok(()) => Ok(serde_json::json!({ "deleted": true, "path": path_str })),
151 Err(e) => Err(format!("Failed to delete: {}", e)),
152 }
153 }
154 "metadata" => {
155 let path_str = data["path"]
156 .as_str()
157 .ok_or_else(|| "Missing 'path' in data".to_string())?;
158 let path_buf = resolve_file_path(workspace_root, path_str)?;
159 match std::fs::metadata(&path_buf) {
160 Ok(meta) => Ok(serde_json::json!({
161 "path": path_str,
162 "is_file": meta.is_file(),
163 "is_dir": meta.is_dir(),
164 "len": meta.len(),
165 "readonly": meta.permissions().readonly(),
166 })),
167 Err(e) => Err(format!("Failed to read metadata: {}", e)),
168 }
169 }
170 _ => Err(format!("Unknown files action: {}", action)),
171 }
172}
173
174async fn handle_execution(action: &str, data: &Value) -> Result<Value, String> {
175 match action {
176 "describe" => Ok(serde_json::json!({
177 "capability": "core.execution",
178 "actions": ["describe", "health", "run"],
179 "runtime": "core",
180 })),
181 "health" => Ok(serde_json::json!({
182 "capability": "core.execution",
183 "healthy": true,
184 "runtime": "core",
185 })),
186 "run" => {
187 let mode = data["mode"]
188 .as_str()
189 .ok_or_else(|| "core.execution/run only supports mode=dry_run".to_string())?;
190 if mode != "dry_run" {
191 return Err("core.execution/run only supports mode=dry_run".into());
192 }
193
194 let command = data["command"]
195 .as_str()
196 .ok_or_else(|| "Missing 'command' in data".to_string())?;
197 if command != "weft-core-version" {
198 return Err(format!(
199 "core.execution/run dry_run does not support command: {}",
200 command
201 ));
202 }
203
204 Ok(serde_json::json!({
205 "mode": "dry_run",
206 "command": command,
207 "dry_run": true,
208 "would_execute": false,
209 "exit_code": 0,
210 "stdout": "weft-core-version",
211 "stderr": "",
212 }))
213 }
214 _ => Err(format!("Unknown execution action: {}", action)),
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::handle_core_capability;
221 use serde_json::json;
222
223 #[tokio::test]
224 async fn execution_describe_and_health_are_available() {
225 let describe = handle_core_capability("core.execution", "describe", &json!({}), None)
226 .await
227 .expect("describe should succeed");
228 assert_eq!(describe["capability"], "core.execution");
229 assert_eq!(describe["runtime"], "core");
230
231 let health = handle_core_capability("core.execution", "health", &json!({}), None)
232 .await
233 .expect("health should succeed");
234 assert_eq!(health["healthy"], true);
235 }
236
237 #[tokio::test]
238 async fn execution_run_dry_run_weft_core_version_is_deterministic() {
239 let result = handle_core_capability(
240 "core.execution",
241 "run",
242 &json!({"mode":"dry_run","command":"weft-core-version"}),
243 None,
244 )
245 .await
246 .expect("dry-run weft-core-version should succeed");
247
248 assert_eq!(result["mode"], "dry_run");
249 assert_eq!(result["command"], "weft-core-version");
250 assert_eq!(result["dry_run"], true);
251 assert_eq!(result["would_execute"], false);
252 assert_eq!(result["exit_code"], 0);
253 assert_eq!(result["stdout"], "weft-core-version");
254 assert_eq!(result["stderr"], "");
255 }
256
257 #[tokio::test]
258 async fn execution_run_rejects_arbitrary_command_without_dry_run_mode() {
259 let error =
260 handle_core_capability("core.execution", "run", &json!({"command":"echo hi"}), None)
261 .await
262 .expect_err("run without dry_run mode should be rejected");
263
264 assert!(error.contains("core.execution/run only supports mode=dry_run"));
265 }
266
267 #[tokio::test]
268 async fn execution_run_rejects_dry_run_unknown_command() {
269 let error = handle_core_capability(
270 "core.execution",
271 "run",
272 &json!({"mode":"dry_run","command":"echo hi"}),
273 None,
274 )
275 .await
276 .expect_err("unknown dry-run command should be rejected");
277
278 assert!(error.contains("dry_run does not support command: echo hi"));
279 }
280
281 #[tokio::test]
282 async fn execution_run_real_process_path_is_not_reachable_by_default() {
283 let error = handle_core_capability(
284 "core.execution",
285 "run",
286 &json!({"mode":"execute","command":"weft-core-version"}),
287 None,
288 )
289 .await
290 .expect_err("non-dry-run execution should be rejected before process spawn");
291
292 assert!(error.contains("core.execution/run only supports mode=dry_run"));
293 }
294}