Skip to main content

tuff_core/
tool.rs

1use std::path::Path;
2
3use crate::error::{Result, TuffError};
4
5pub fn validate_json_schema(value: &serde_json::Value) -> Result<()> {
6    let obj = value
7        .as_object()
8        .ok_or_else(|| TuffError::usage("parameters must be a JSON object with 'type: object'"))?;
9
10    let schema_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
11    if schema_type != "object" {
12        return Err(TuffError::usage(format!(
13            "parameters 'type' must be 'object', got '{}'",
14            schema_type
15        )));
16    }
17
18    if !obj.contains_key("properties") {
19        return Err(TuffError::usage(
20            "parameters must have a 'properties' section defining the tool's input schema",
21        ));
22    }
23
24    let properties = obj.get("properties").and_then(|v| v.as_object());
25    if properties.is_none() || properties.unwrap().is_empty() {
26        return Err(TuffError::usage(
27            "parameters 'properties' must contain at least one parameter definition",
28        ));
29    }
30
31    if obj.contains_key("required") {
32        let required = obj.get("required").and_then(|v| v.as_array());
33        if required.is_none() {
34            return Err(TuffError::usage(
35                "parameters 'required' must be an array of field names",
36            ));
37        }
38    }
39
40    Ok(())
41}
42
43pub fn validate_entrypoint(primitive_dir: &Path, entrypoint: &str) -> Result<()> {
44    check_path_traversal(entrypoint)?;
45
46    let path = primitive_dir.join(entrypoint);
47    if !path.exists() {
48        return Err(TuffError::not_found(format!(
49            "implementation entrypoint not found: {}",
50            path.display()
51        )));
52    }
53
54    if !path.is_file() {
55        return Err(TuffError::usage(format!(
56            "implementation entrypoint must be a file, not a directory: {}",
57            path.display()
58        )));
59    }
60
61    Ok(())
62}
63
64pub fn check_path_traversal(entrypoint: &str) -> Result<()> {
65    if entrypoint.is_empty() {
66        return Err(TuffError::usage(
67            "implementation entrypoint must not be empty",
68        ));
69    }
70
71    if entrypoint.starts_with('/') {
72        return Err(TuffError::usage(
73            "implementation entrypoint must be a relative path, not absolute",
74        ));
75    }
76
77    let clean = entrypoint.trim_start_matches("./");
78
79    for component in clean.split('/') {
80        if component == ".." {
81            return Err(TuffError::refused(
82                "implementation entrypoint must not use '..' — path traversal is not allowed",
83            ));
84        }
85    }
86
87    Ok(())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94    use std::fs;
95    use tempfile::TempDir;
96
97    #[test]
98    fn schema_valid_object_with_properties() {
99        let schema = json!({
100            "type": "object",
101            "properties": { "target": { "type": "string" } },
102            "required": ["target"]
103        });
104        assert!(validate_json_schema(&schema).is_ok());
105    }
106
107    #[test]
108    fn schema_rejects_missing_type() {
109        assert!(validate_json_schema(&json!({"properties": {"x": {"type": "string"}}})).is_err());
110    }
111
112    #[test]
113    fn schema_rejects_non_object_type() {
114        assert!(validate_json_schema(&json!({"type": "string", "properties": {}})).is_err());
115    }
116
117    #[test]
118    fn schema_rejects_missing_properties() {
119        assert!(validate_json_schema(&json!({"type": "object"})).is_err());
120    }
121
122    #[test]
123    fn schema_rejects_empty_properties() {
124        assert!(validate_json_schema(&json!({"type": "object", "properties": {}})).is_err());
125    }
126
127    #[test]
128    fn schema_accepts_no_required_field() {
129        assert!(
130            validate_json_schema(
131                &json!({"type": "object", "properties": {"file": {"type": "string"}}})
132            )
133            .is_ok()
134        );
135    }
136
137    #[test]
138    fn path_traversal_blocks_dot_dot() {
139        assert!(check_path_traversal("../etc/passwd").is_err());
140        assert!(check_path_traversal("scripts/../../../etc/passwd").is_err());
141    }
142
143    #[test]
144    fn path_traversal_blocks_absolute() {
145        assert!(check_path_traversal("/etc/passwd").is_err());
146    }
147
148    #[test]
149    fn path_traversal_blocks_empty() {
150        assert!(check_path_traversal("").is_err());
151    }
152
153    #[test]
154    fn path_traversal_allows_relative() {
155        assert!(check_path_traversal("run.sh").is_ok());
156        assert!(check_path_traversal("./run.sh").is_ok());
157        assert!(check_path_traversal("scripts/run.sh").is_ok());
158    }
159
160    #[test]
161    fn entrypoint_rejects_non_existent_file() {
162        let tmp = TempDir::new().unwrap();
163        assert!(validate_entrypoint(tmp.path(), "missing.sh").is_err());
164    }
165
166    #[test]
167    fn entrypoint_accepts_valid_file() {
168        let tmp = TempDir::new().unwrap();
169        fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
170        assert!(validate_entrypoint(tmp.path(), "run.sh").is_ok());
171    }
172
173    #[test]
174    fn entrypoint_rejects_directory() {
175        let tmp = TempDir::new().unwrap();
176        fs::create_dir_all(tmp.path().join("subdir")).unwrap();
177        assert!(validate_entrypoint(tmp.path(), "subdir").is_err());
178    }
179}