1use std::io::Seek;
6use std::path::{Path, PathBuf};
7
8use globset::{Glob, GlobSet, GlobSetBuilder};
9
10use crate::config::Limits;
11use crate::error::{Error, Result};
12use crate::guard::Guard;
13use crate::local::LocalScope;
14use crate::remote::Remote;
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(self.too_large(&format!("`{local_raw}` ({total} bytes)")));
118 }
119
120 for (path, _) in &plan {
123 let mut file = self.local.open_resolved(path)?;
124 self.guard.ensure_upload_reader(path, &mut file)?;
125 }
126
127 self.remote.ensure_dir(&remote_base).await?;
133 for (path, dest) in &plan {
134 let file = self.local.open_resolved(path)?;
135 let left = self.limits.max_transfer_bytes.saturating_sub(report.bytes);
136 let (_, bytes) = self
137 .remote
138 .upload_file(file, dest, self.guard.stream(path), left)
139 .await?;
140 report.files += 1;
141 report.bytes += bytes;
142 }
143 report.destination = remote_base;
144 Ok(report)
145 }
146
147 async fn send_file(
148 &self,
149 local: &Path,
150 dest: &str,
151 report: &mut TransferReport,
152 ) -> Result<u64> {
153 let mut file = self.local.open_resolved(local)?;
154 let len = file.metadata()?.len();
155 let max = self.limits.max_transfer_bytes;
156 if len > max {
157 return Err(self.too_large(&format!("`{}` ({len} bytes)", local.display())));
158 }
159 self.guard.ensure_upload_reader(local, &mut file)?;
160 file.rewind()?;
161 let (_, bytes) = self
162 .remote
163 .upload_file(file, dest, self.guard.stream(local), max)
164 .await?;
165 report.files += 1;
166 Ok(bytes)
167 }
168
169 fn too_large(&self, what: &str) -> Error {
173 Error::Invalid(format!(
174 "{what} exceeds the transfer limit of {} bytes; raise `max_transfer_bytes` under \
175 [limits] in .farhand.toml to allow it",
176 self.limits.max_transfer_bytes
177 ))
178 }
179
180 pub async fn download(&self, remote_raw: &str, local_raw: &str) -> Result<TransferReport> {
184 let remote_path = self.remote.resolve(remote_raw).await?;
185 let local = self.local.resolve_for_write(local_raw)?;
186 let mut report = TransferReport::default();
187 let max = self.limits.max_transfer_bytes;
188
189 match self.remote.exists(&remote_path).await? {
190 None => Err(Error::Invalid(format!(
191 "`{remote_path}` does not exist on the remote"
192 ))),
193 Some(false) => {
194 let dest = if local.is_dir() || local_raw.ends_with('/') {
195 let name = remote_path
198 .rsplit(['/', '\\'])
199 .next()
200 .unwrap_or(&remote_path);
201 local.join(name)
202 } else {
203 local
204 };
205 let dest = self.local_target(&dest)?;
206 let len = self.remote.file_size(&remote_path).await?;
207 if len > max {
208 return Err(self.too_large(&format!("`{remote_path}` ({len} bytes)")));
209 }
210 let bytes = self.fetch(&remote_path, &dest, max).await?;
211 report.files = 1;
212 report.bytes = bytes;
213 report.destination = dest.display().to_string();
214 Ok(report)
215 }
216 Some(true) => {
217 let files = self
218 .remote
219 .walk_files(&remote_path, self.limits.max_transfer_files)
220 .await?;
221 if files.len() > self.limits.max_transfer_files {
222 return Err(Error::Invalid(format!(
223 "more than {} files; narrow the download",
224 self.limits.max_transfer_files
225 )));
226 }
227 let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
232 for f in files {
233 let rel = f
235 .strip_prefix(&remote_path)
236 .unwrap_or(&f)
237 .trim_start_matches(['/', '\\'])
238 .replace('\\', "/");
239 if rel.is_empty() || rel.split('/').any(|seg| seg == "..") {
240 report.skipped.push(f.clone());
241 continue;
242 }
243 let dest = self.local_target(&local.join(&rel))?;
244 plan.push((f, dest));
245 }
246 for (f, dest) in plan {
247 let len = self.remote.file_size(&f).await?;
248 if report.bytes + len > max {
249 return Err(self.too_large(&format!(
250 "`{remote_raw}` (at least {} bytes)",
251 report.bytes + len
252 )));
253 }
254 let bytes = self.fetch(&f, &dest, max - report.bytes).await?;
255 report.files += 1;
256 report.bytes += bytes;
257 }
258 report.destination = local.display().to_string();
259 Ok(report)
260 }
261 }
262 }
263}
264
265impl Transfer<'_> {
266 async fn fetch(&self, remote_path: &str, dest: &Path, max: u64) -> Result<u64> {
271 let part = self.local.create_part(dest)?;
272 let bytes = self
273 .remote
274 .download_into(remote_path, part.file(), max)
275 .await?;
276 part.commit()?;
277 Ok(bytes)
278 }
279
280 fn local_target(&self, dest: &Path) -> Result<PathBuf> {
284 let dest = self.local.resolve_for_write(&dest.to_string_lossy())?;
285 let rel = self
286 .local
287 .roots()
288 .iter()
289 .find_map(|r| dest.strip_prefix(r).ok())
290 .unwrap_or(&dest);
291 self.guard.ensure_download(rel)?;
292 Ok(dest)
293 }
294}
295
296fn build_excludes(extra: &[String]) -> Result<GlobSet> {
297 let mut b = GlobSetBuilder::new();
298 for g in DEFAULT_EXCLUDES
299 .iter()
300 .map(|s| s.to_string())
301 .chain(extra.iter().cloned())
302 {
303 b.add(Glob::new(&g).map_err(|e| Error::Invalid(format!("exclude `{g}`: {e}")))?);
304 }
305 b.build().map_err(|e| Error::Invalid(e.to_string()))
306}
307
308fn is_excluded(path: &Path, root: &Path, set: &GlobSet) -> bool {
309 if path == root {
310 return false;
311 }
312 let name = path
313 .file_name()
314 .map(|n| n.to_string_lossy())
315 .unwrap_or_default();
316 if set.is_match(name.as_ref()) {
317 return true;
318 }
319 path.strip_prefix(root)
320 .map(|rel| set.is_match(rel))
321 .unwrap_or(false)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn default_excludes_match_names_anywhere() {
330 let set = build_excludes(&["*.log".into()]).unwrap();
331 let root = Path::new("/p");
332 assert!(is_excluded(Path::new("/p/a/node_modules"), root, &set));
333 assert!(is_excluded(Path::new("/p/x.log"), root, &set));
334 assert!(!is_excluded(Path::new("/p/src/main.rs"), root, &set));
335 assert!(!is_excluded(root, root, &set));
336 }
337}