1use std::path::{Path, PathBuf};
6
7use globset::{Glob, GlobSet, GlobSetBuilder};
8
9use crate::config::Limits;
10use crate::error::{Error, Result};
11use crate::guard::Guard;
12use crate::local::LocalScope;
13use crate::remote::Remote;
14use crate::text::shell_quote;
15
16pub const DEFAULT_EXCLUDES: &[&str] = &[
19 ".git",
20 "node_modules",
21 "target",
22 ".venv",
23 "venv",
24 "__pycache__",
25 ".DS_Store",
26 ".idea",
27 ".mypy_cache",
28 ".pytest_cache",
29 "dist",
30 "build",
31];
32
33#[derive(Debug, Default)]
34pub struct TransferReport {
35 pub files: usize,
36 pub bytes: u64,
37 pub skipped: Vec<String>,
38 pub destination: String,
39}
40
41pub struct Transfer<'a> {
42 pub remote: &'a Remote,
43 pub guard: &'a Guard,
44 pub local: &'a LocalScope,
45 pub limits: &'a Limits,
46}
47
48impl Transfer<'_> {
49 pub async fn upload(
53 &self,
54 local_raw: &str,
55 remote_raw: &str,
56 extra_excludes: &[String],
57 ) -> Result<TransferReport> {
58 let local = self.local.resolve_existing(local_raw)?;
59 let remote_base = self.remote.resolve(remote_raw).await?;
60 let excludes = build_excludes(extra_excludes)?;
61 let mut report = TransferReport::default();
62
63 if local.is_file() {
64 let dest = if remote_raw.ends_with('/')
65 || self.remote.exists(&remote_base).await? == Some(true)
66 {
67 let name = local.file_name().unwrap().to_string_lossy();
68 format!("{remote_base}/{name}")
69 } else {
70 remote_base
71 };
72 let bytes = self.send_file(&local, &dest, &mut report).await?;
73 report.bytes = bytes;
74 report.destination = dest;
75 return Ok(report);
76 }
77 if !local.is_dir() {
78 return Err(Error::Invalid(format!(
79 "`{}` is neither a file nor a directory",
80 local.display()
81 )));
82 }
83
84 let mut plan: Vec<(PathBuf, String)> = Vec::new();
86 let mut total: u64 = 0;
87 let walker = walkdir::WalkDir::new(&local)
88 .follow_links(false)
89 .into_iter();
90 for entry in walker.filter_entry(|e| !is_excluded(e.path(), &local, &excludes)) {
91 let entry = entry.map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
92 let ft = entry.file_type();
93 if ft.is_symlink() {
94 report
95 .skipped
96 .push(format!("{} (symlink)", entry.path().display()));
97 continue;
98 }
99 if !ft.is_file() {
100 continue;
101 }
102 let rel = entry.path().strip_prefix(&local).unwrap();
103 let rel_str = rel.to_string_lossy().replace('\\', "/");
104 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
105 plan.push((
106 entry.path().to_path_buf(),
107 format!("{remote_base}/{rel_str}"),
108 ));
109 if plan.len() > self.limits.max_transfer_files {
110 return Err(Error::Invalid(format!(
111 "more than {} files; narrow the upload or add excludes",
112 self.limits.max_transfer_files
113 )));
114 }
115 }
116 if total > self.limits.max_transfer_bytes {
117 return Err(Error::Invalid(format!(
118 "{total} bytes exceeds the transfer limit of {} bytes",
119 self.limits.max_transfer_bytes
120 )));
121 }
122
123 for (path, _) in &plan {
126 let bytes = std::fs::read(path)?;
127 self.guard.ensure_upload(path, &bytes)?;
128 }
129
130 let mut dirs: Vec<String> = plan
132 .iter()
133 .filter_map(|(_, dest)| {
134 Path::new(dest)
135 .parent()
136 .map(|p| p.to_string_lossy().into_owned())
137 })
138 .collect();
139 dirs.push(remote_base.clone());
140 dirs.sort();
141 dirs.dedup();
142 let mkdir = format!(
143 "mkdir -p {}",
144 dirs.iter()
145 .map(|d| shell_quote(d))
146 .collect::<Vec<_>>()
147 .join(" ")
148 );
149 let out = self.remote.exec(&mkdir, None, Some(60)).await?;
150 if out.exit_code != 0 {
151 return Err(Error::Ssh(format!(
152 "mkdir -p failed: {}",
153 String::from_utf8_lossy(&out.stderr).trim()
154 )));
155 }
156
157 for (path, dest) in &plan {
158 let bytes = std::fs::read(path)?;
159 self.remote.write_file(dest, &bytes).await?;
160 report.files += 1;
161 report.bytes += bytes.len() as u64;
162 }
163 report.destination = remote_base;
164 Ok(report)
165 }
166
167 async fn send_file(
168 &self,
169 local: &Path,
170 dest: &str,
171 report: &mut TransferReport,
172 ) -> Result<u64> {
173 let meta = std::fs::metadata(local)?;
174 if meta.len() > self.limits.max_transfer_bytes {
175 return Err(Error::Invalid(format!(
176 "{} bytes exceeds the transfer limit of {} bytes",
177 meta.len(),
178 self.limits.max_transfer_bytes
179 )));
180 }
181 let bytes = std::fs::read(local)?;
182 self.guard.ensure_upload(local, &bytes)?;
183 self.remote.write_file(dest, &bytes).await?;
184 report.files += 1;
185 Ok(bytes.len() as u64)
186 }
187
188 pub async fn download(&self, remote_raw: &str, local_raw: &str) -> Result<TransferReport> {
192 let remote_path = self.remote.resolve(remote_raw).await?;
193 let local = self.local.resolve_for_write(local_raw)?;
194 let mut report = TransferReport::default();
195 let max = self.limits.max_transfer_bytes as usize;
196
197 match self.remote.exists(&remote_path).await? {
198 None => Err(Error::Invalid(format!(
199 "`{remote_path}` does not exist on the remote"
200 ))),
201 Some(false) => {
202 let dest = if local.is_dir() || local_raw.ends_with('/') {
203 local.join(Path::new(&remote_path).file_name().unwrap_or_default())
204 } else {
205 local
206 };
207 let dest = self.local_target(&dest)?;
208 let (bytes, len) = self.remote.read_file(&remote_path, max).await?;
209 if len as usize > max {
210 return Err(Error::Invalid(format!(
211 "`{remote_path}` exceeds the transfer limit"
212 )));
213 }
214 if let Some(parent) = dest.parent() {
215 std::fs::create_dir_all(parent)?;
216 }
217 std::fs::write(&dest, &bytes)?;
218 report.files = 1;
219 report.bytes = bytes.len() as u64;
220 report.destination = dest.display().to_string();
221 Ok(report)
222 }
223 Some(true) => {
224 let list = self
225 .remote
226 .exec(
227 &format!("find {} -type f -print", shell_quote(&remote_path)),
228 None,
229 Some(120),
230 )
231 .await?;
232 if list.exit_code != 0 {
233 return Err(Error::Ssh(
234 String::from_utf8_lossy(&list.stderr).trim().to_string(),
235 ));
236 }
237 let files: Vec<String> = String::from_utf8_lossy(&list.stdout)
238 .lines()
239 .map(str::to_string)
240 .collect();
241 if files.len() > self.limits.max_transfer_files {
242 return Err(Error::Invalid(format!(
243 "more than {} files; narrow the download",
244 self.limits.max_transfer_files
245 )));
246 }
247 let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
252 for f in files {
253 let rel = f
254 .strip_prefix(&remote_path)
255 .unwrap_or(&f)
256 .trim_start_matches('/');
257 if rel.is_empty() || rel.split('/').any(|seg| seg == "..") {
258 report.skipped.push(f.clone());
259 continue;
260 }
261 let dest = self.local_target(&local.join(rel))?;
262 plan.push((f, dest));
263 }
264 for (f, dest) in plan {
265 let (bytes, len) = self.remote.read_file(&f, max).await?;
266 if report.bytes + len > self.limits.max_transfer_bytes {
267 return Err(Error::Invalid("transfer limit exceeded".into()));
268 }
269 if let Some(parent) = dest.parent() {
270 std::fs::create_dir_all(parent)?;
271 }
272 std::fs::write(&dest, &bytes)?;
273 report.files += 1;
274 report.bytes += bytes.len() as u64;
275 }
276 report.destination = local.display().to_string();
277 Ok(report)
278 }
279 }
280 }
281}
282
283impl Transfer<'_> {
284 fn local_target(&self, dest: &Path) -> Result<PathBuf> {
288 let dest = self.local.resolve_for_write(&dest.to_string_lossy())?;
289 let rel = self
290 .local
291 .roots()
292 .iter()
293 .find_map(|r| dest.strip_prefix(r).ok())
294 .unwrap_or(&dest);
295 self.guard.ensure_download(rel)?;
296 Ok(dest)
297 }
298}
299
300fn build_excludes(extra: &[String]) -> Result<GlobSet> {
301 let mut b = GlobSetBuilder::new();
302 for g in DEFAULT_EXCLUDES
303 .iter()
304 .map(|s| s.to_string())
305 .chain(extra.iter().cloned())
306 {
307 b.add(Glob::new(&g).map_err(|e| Error::Invalid(format!("exclude `{g}`: {e}")))?);
308 }
309 b.build().map_err(|e| Error::Invalid(e.to_string()))
310}
311
312fn is_excluded(path: &Path, root: &Path, set: &GlobSet) -> bool {
313 if path == root {
314 return false;
315 }
316 let name = path
317 .file_name()
318 .map(|n| n.to_string_lossy())
319 .unwrap_or_default();
320 if set.is_match(name.as_ref()) {
321 return true;
322 }
323 path.strip_prefix(root)
324 .map(|rel| set.is_match(rel))
325 .unwrap_or(false)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn default_excludes_match_names_anywhere() {
334 let set = build_excludes(&["*.log".into()]).unwrap();
335 let root = Path::new("/p");
336 assert!(is_excluded(Path::new("/p/a/node_modules"), root, &set));
337 assert!(is_excluded(Path::new("/p/x.log"), root, &set));
338 assert!(!is_excluded(Path::new("/p/src/main.rs"), root, &set));
339 assert!(!is_excluded(root, root, &set));
340 }
341}