Skip to main content

weft_core/api/
core_capabilities.rs

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}