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}