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