1use crate::client::SshClient;
2use crate::config::ShuttleConfig;
3use serde_json::{json, Value};
4use std::path::Path;
5use std::sync::Arc;
6use tokio::io::AsyncWriteExt;
7use tokio::sync::Mutex;
8
9const BLOCKED_HOME_PREFIXES: &[&str] = &[
11 ".config/styrene",
12 ".ssh",
13 ".aws",
14 ".gnupg",
15 ".gpg",
16 ".omegon",
17 ".kube",
18 ".docker",
19 ".npmrc",
20 ".pypirc",
21 ".netrc",
22 ".config/gcloud",
23 ".azure",
24];
25
26const BLOCKED_ABSOLUTE_PREFIXES: &[&str] = &[
29 "/etc",
30 "/private/etc",
31 "/var/run/secrets",
32 "/private/var/run/secrets",
33 "/proc",
34 "/sys",
35 "/dev",
36 "/Library/Keychains",
37 "/System",
38];
39
40fn validate_local_path(path: &str) -> omegon_extension::Result<std::path::PathBuf> {
47 let p = Path::new(path);
48
49 let canonical = p
50 .canonicalize()
51 .or_else(|_| {
52 p.parent()
53 .and_then(|parent| parent.canonicalize().ok())
54 .map(|parent| parent.join(p.file_name().unwrap_or_default()))
55 .ok_or_else(|| {
56 std::io::Error::new(std::io::ErrorKind::NotFound, "parent dir not found")
57 })
58 })
59 .map_err(|e| {
60 omegon_extension::Error::invalid_params(format!("invalid path: {e}"))
61 })?;
62
63 let canonical_str = canonical.to_string_lossy();
64 if canonical_str.contains("..") {
65 return Err(omegon_extension::Error::invalid_params(
66 "path traversal not allowed",
67 ));
68 }
69
70 for prefix in BLOCKED_ABSOLUTE_PREFIXES {
72 if canonical.starts_with(prefix) {
73 return Err(omegon_extension::Error::invalid_params(
74 "access to system directories is blocked",
75 ));
76 }
77 }
78
79 if let Some(home) = dirs::home_dir() {
81 for prefix in BLOCKED_HOME_PREFIXES {
82 let sensitive = home.join(prefix);
83 if canonical.starts_with(&sensitive) {
84 return Err(omegon_extension::Error::invalid_params(
85 "access to credential/config directories is blocked",
86 ));
87 }
88 }
89 }
90
91 Ok(canonical)
92}
93
94pub async fn scp_push(
96 client: &Arc<Mutex<SshClient>>,
97 _config: &ShuttleConfig,
98 params: &Value,
99) -> omegon_extension::Result<Value> {
100 let local_path = params
101 .get("local_path")
102 .and_then(|v| v.as_str())
103 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'local_path'"))?;
104
105 let remote_path = params
106 .get("remote_path")
107 .and_then(|v| v.as_str())
108 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'remote_path'"))?;
109
110 let local = validate_local_path(local_path)?;
111 if !local.exists() {
112 return Err(omegon_extension::Error::invalid_params(format!(
113 "local file not found: {local_path}"
114 )));
115 }
116 if !local.is_file() {
117 return Err(omegon_extension::Error::invalid_params(format!(
118 "local path is not a file: {local_path}"
119 )));
120 }
121
122 let data = tokio::fs::read(&local)
123 .await
124 .map_err(|e| omegon_extension::Error::internal_error(format!("read {local_path}: {e}")))?;
125
126 let bytes_written = data.len();
127
128 let client_guard = client.lock().await;
129 let sftp = client_guard
130 .sftp()
131 .await
132 .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?;
133
134 sftp.create(remote_path)
135 .await
136 .map_err(|e| omegon_extension::Error::internal_error(format!("sftp create: {e}")))?
137 .write_all(&data)
138 .await
139 .map_err(|e| omegon_extension::Error::internal_error(format!("sftp write: {e}")))?;
140
141 Ok(json!({
142 "host": client_guard.host_name(),
143 "local_path": local_path,
144 "remote_path": remote_path,
145 "bytes_written": bytes_written,
146 }))
147}
148
149pub async fn scp_pull(
151 client: &Arc<Mutex<SshClient>>,
152 _config: &ShuttleConfig,
153 params: &Value,
154) -> omegon_extension::Result<Value> {
155 let remote_path = params
156 .get("remote_path")
157 .and_then(|v| v.as_str())
158 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'remote_path'"))?;
159
160 let local_path = params
161 .get("local_path")
162 .and_then(|v| v.as_str())
163 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'local_path'"))?;
164
165 let local = validate_local_path(local_path)?;
166
167 let client_guard = client.lock().await;
168 let sftp = client_guard
169 .sftp()
170 .await
171 .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?;
172
173 let data = sftp
174 .read(remote_path)
175 .await
176 .map_err(|e| omegon_extension::Error::internal_error(format!("sftp read: {e}")))?;
177
178 tokio::fs::write(&local, &data)
179 .await
180 .map_err(|e| omegon_extension::Error::internal_error(format!("write {local_path}: {e}")))?;
181
182 Ok(json!({
183 "host": client_guard.host_name(),
184 "remote_path": remote_path,
185 "local_path": local_path,
186 "bytes_written": data.len(),
187 }))
188}
189
190pub async fn sftp_ls(
192 client: &Arc<Mutex<SshClient>>,
193 _config: &ShuttleConfig,
194 params: &Value,
195) -> omegon_extension::Result<Value> {
196 let path = params
197 .get("path")
198 .and_then(|v| v.as_str())
199 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'path'"))?;
200
201 let client_guard = client.lock().await;
202 let sftp = client_guard
203 .sftp()
204 .await
205 .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?;
206
207 let entries = sftp
208 .read_dir(path)
209 .await
210 .map_err(|e| omegon_extension::Error::internal_error(format!("sftp readdir: {e}")))?;
211
212 let items: Vec<Value> = entries
213 .into_iter()
214 .map(|entry| {
215 json!({
216 "name": entry.file_name(),
217 "size": entry.metadata().size.unwrap_or(0),
218 "is_dir": entry.metadata().is_dir(),
219 })
220 })
221 .collect();
222
223 Ok(json!({
224 "host": client_guard.host_name(),
225 "path": path,
226 "entries": items,
227 }))
228}
229
230pub async fn sftp_read(
232 client: &Arc<Mutex<SshClient>>,
233 config: &ShuttleConfig,
234 params: &Value,
235) -> omegon_extension::Result<Value> {
236 let path = params
237 .get("path")
238 .and_then(|v| v.as_str())
239 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'path'"))?;
240
241 let max_bytes = params
242 .get("max_bytes")
243 .and_then(|v| v.as_u64())
244 .map(|v| v as usize)
245 .unwrap_or(config.max_output_bytes)
246 .min(config.max_output_bytes);
247
248 let client_guard = client.lock().await;
249 let sftp = client_guard
250 .sftp()
251 .await
252 .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?;
253
254 let data = sftp
255 .read(path)
256 .await
257 .map_err(|e| omegon_extension::Error::internal_error(format!("sftp read: {e}")))?;
258
259 let truncated = data.len() > max_bytes;
260 let content = if truncated {
261 String::from_utf8_lossy(&data[..max_bytes]).into_owned()
262 } else {
263 String::from_utf8_lossy(&data).into_owned()
264 };
265
266 Ok(json!({
267 "host": client_guard.host_name(),
268 "path": path,
269 "content": content,
270 "size": data.len(),
271 "truncated": truncated,
272 }))
273}