Skip to main content

farhand_core/
transfer.rs

1//! Moving files between the allowlisted local directories and the remote.
2//! Uploads go through the secret guard file by file; downloads only ever
3//! land inside the allowlist.
4
5use 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
16/// Directory names skipped by default when uploading a tree. They are
17/// either regenerated on the remote or never wanted there.
18pub 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    /// Upload a local file or directory. A file lands at `remote_path`
50    /// (or inside it when `remote_path` is an existing directory or ends
51    /// with `/`). A directory's *contents* land inside `remote_path`.
52    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        // Plan first so limits are enforced before anything is sent.
85        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        // The guard runs over the whole plan before the first byte moves, so a
121        // refused file never leaves a half-uploaded tree behind.
122        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        // `upload_file` creates parents as it goes; the remote remembers
128        // which directories it has seen, so a deep tree costs one check per
129        // directory, not per file. Each file is opened again and scanned
130        // again as it is sent: the scan above is for the all-or-nothing
131        // answer, this one is what makes it hold for the bytes that leave.
132        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    /// The refusal for anything over `max_transfer_bytes`, naming the knob:
170    /// the model cannot change the config itself, but it can say what to
171    /// change.
172    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    /// Download a remote file or directory into the allowlist. A file lands
181    /// at `local_path` (or inside it when that is an existing directory or
182    /// ends with `/`); a directory's contents land inside `local_path`.
183    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                    // The remote path may use `\`, which is an ordinary
196                    // character to the local `Path` on POSIX.
197                    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                // Names come from the remote, so every destination is checked
228                // like a model-supplied path before anything is written:
229                // no `..`, no escaping through a local symlink, nothing an
230                // editor or agent would trust.
231                let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
232                for f in files {
233                    // Native remote separators become `/` locally.
234                    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    /// Download one remote file to `dest`, at most `max` bytes. The data
267    /// goes to a temporary file in the destination folder, reached without
268    /// following a symlink, and is renamed into place once complete, so an
269    /// interrupted download leaves nothing that looks finished.
270    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    /// Re-check a download destination: it must still resolve inside the
281    /// allowlist (a symlink under the root could point elsewhere) and its
282    /// path relative to that root must pass the guard.
283    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}