Skip to main content

shuttle/
transfer.rs

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
9/// Sensitive path prefixes under $HOME that are always blocked.
10const 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
26/// Absolute path prefixes outside $HOME that are always blocked.
27/// Includes /private/* variants for macOS where /etc → /private/etc.
28const 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
40/// Validate that a local path is safe for file transfer operations.
41///
42/// Blocks:
43/// - Path traversal (`..` after canonicalization)
44/// - Sensitive directories under `$HOME` (credentials, identity, config)
45/// - System directories (`/etc`, `/proc`, `/var/run/secrets`)
46fn 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    // Block system directories (absolute paths outside $HOME)
71    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    // Block sensitive locations under $HOME
80    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
94/// Copy a local file to a remote host via SFTP.
95pub 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
149/// Copy a remote file to the local machine via SFTP.
150pub 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
190/// List files at a path on a remote host.
191pub 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
230/// Read a remote file's contents without copying it locally.
231pub 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}