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::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
15/// Directory names skipped by default when uploading a tree. They are
16/// either regenerated on the remote or never wanted there.
17pub 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    /// Upload a local file or directory. A file lands at `remote_path`
49    /// (or inside it when `remote_path` is an existing directory or ends
50    /// with `/`). A directory's *contents* land inside `remote_path`.
51    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        // Plan first so limits are enforced before anything is sent.
84        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(self.too_large(&format!("`{local_raw}` ({total} bytes)")));
117        }
118
119        // The guard runs over the whole plan before the first byte moves, so a
120        // refused file never leaves a half-uploaded tree behind.
121        for (path, _) in &plan {
122            self.guard.ensure_upload_file(path)?;
123        }
124
125        // `upload_file` creates parents as it goes; the remote remembers
126        // which directories it has seen, so a deep tree costs one check per
127        // directory, not per file.
128        self.remote.ensure_dir(&remote_base).await?;
129        for (path, dest) in &plan {
130            let (_, bytes) = self.remote.upload_file(path, dest).await?;
131            report.files += 1;
132            report.bytes += bytes;
133        }
134        report.destination = remote_base;
135        Ok(report)
136    }
137
138    async fn send_file(
139        &self,
140        local: &Path,
141        dest: &str,
142        report: &mut TransferReport,
143    ) -> Result<u64> {
144        let meta = std::fs::metadata(local)?;
145        if meta.len() > self.limits.max_transfer_bytes {
146            return Err(self.too_large(&format!("`{}` ({} bytes)", local.display(), meta.len())));
147        }
148        self.guard.ensure_upload_file(local)?;
149        let (_, bytes) = self.remote.upload_file(local, dest).await?;
150        report.files += 1;
151        Ok(bytes)
152    }
153
154    /// The refusal for anything over `max_transfer_bytes`, naming the knob:
155    /// the model cannot change the config itself, but it can say what to
156    /// change.
157    fn too_large(&self, what: &str) -> Error {
158        Error::Invalid(format!(
159            "{what} exceeds the transfer limit of {} bytes; raise `max_transfer_bytes` under \
160             [limits] in .farhand.toml to allow it",
161            self.limits.max_transfer_bytes
162        ))
163    }
164
165    /// Download a remote file or directory into the allowlist. A file lands
166    /// at `local_path` (or inside it when that is an existing directory or
167    /// ends with `/`); a directory's contents land inside `local_path`.
168    pub async fn download(&self, remote_raw: &str, local_raw: &str) -> Result<TransferReport> {
169        let remote_path = self.remote.resolve(remote_raw).await?;
170        let local = self.local.resolve_for_write(local_raw)?;
171        let mut report = TransferReport::default();
172        let max = self.limits.max_transfer_bytes;
173
174        match self.remote.exists(&remote_path).await? {
175            None => Err(Error::Invalid(format!(
176                "`{remote_path}` does not exist on the remote"
177            ))),
178            Some(false) => {
179                let dest = if local.is_dir() || local_raw.ends_with('/') {
180                    // The remote path may use `\`, which is an ordinary
181                    // character to the local `Path` on POSIX.
182                    let name = remote_path
183                        .rsplit(['/', '\\'])
184                        .next()
185                        .unwrap_or(&remote_path);
186                    local.join(name)
187                } else {
188                    local
189                };
190                let dest = self.local_target(&dest)?;
191                let len = self.remote.file_size(&remote_path).await?;
192                if len > max {
193                    return Err(self.too_large(&format!("`{remote_path}` ({len} bytes)")));
194                }
195                if let Some(parent) = dest.parent() {
196                    std::fs::create_dir_all(parent)?;
197                }
198                let bytes = self.remote.download_file(&remote_path, &dest).await?;
199                report.files = 1;
200                report.bytes = bytes;
201                report.destination = dest.display().to_string();
202                Ok(report)
203            }
204            Some(true) => {
205                let files = self
206                    .remote
207                    .walk_files(&remote_path, self.limits.max_transfer_files)
208                    .await?;
209                if files.len() > self.limits.max_transfer_files {
210                    return Err(Error::Invalid(format!(
211                        "more than {} files; narrow the download",
212                        self.limits.max_transfer_files
213                    )));
214                }
215                // Names come from the remote, so every destination is checked
216                // like a model-supplied path before anything is written:
217                // no `..`, no escaping through a local symlink, nothing an
218                // editor or agent would trust.
219                let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
220                for f in files {
221                    // Native remote separators become `/` locally.
222                    let rel = f
223                        .strip_prefix(&remote_path)
224                        .unwrap_or(&f)
225                        .trim_start_matches(['/', '\\'])
226                        .replace('\\', "/");
227                    if rel.is_empty() || rel.split('/').any(|seg| seg == "..") {
228                        report.skipped.push(f.clone());
229                        continue;
230                    }
231                    let dest = self.local_target(&local.join(&rel))?;
232                    plan.push((f, dest));
233                }
234                for (f, dest) in plan {
235                    let len = self.remote.file_size(&f).await?;
236                    if report.bytes + len > max {
237                        return Err(self.too_large(&format!(
238                            "`{remote_raw}` (at least {} bytes)",
239                            report.bytes + len
240                        )));
241                    }
242                    if let Some(parent) = dest.parent() {
243                        std::fs::create_dir_all(parent)?;
244                    }
245                    let bytes = self.remote.download_file(&f, &dest).await?;
246                    report.files += 1;
247                    report.bytes += bytes;
248                }
249                report.destination = local.display().to_string();
250                Ok(report)
251            }
252        }
253    }
254}
255
256impl Transfer<'_> {
257    /// Re-check a download destination: it must still resolve inside the
258    /// allowlist (a symlink under the root could point elsewhere) and its
259    /// path relative to that root must pass the guard.
260    fn local_target(&self, dest: &Path) -> Result<PathBuf> {
261        let dest = self.local.resolve_for_write(&dest.to_string_lossy())?;
262        let rel = self
263            .local
264            .roots()
265            .iter()
266            .find_map(|r| dest.strip_prefix(r).ok())
267            .unwrap_or(&dest);
268        self.guard.ensure_download(rel)?;
269        Ok(dest)
270    }
271}
272
273fn build_excludes(extra: &[String]) -> Result<GlobSet> {
274    let mut b = GlobSetBuilder::new();
275    for g in DEFAULT_EXCLUDES
276        .iter()
277        .map(|s| s.to_string())
278        .chain(extra.iter().cloned())
279    {
280        b.add(Glob::new(&g).map_err(|e| Error::Invalid(format!("exclude `{g}`: {e}")))?);
281    }
282    b.build().map_err(|e| Error::Invalid(e.to_string()))
283}
284
285fn is_excluded(path: &Path, root: &Path, set: &GlobSet) -> bool {
286    if path == root {
287        return false;
288    }
289    let name = path
290        .file_name()
291        .map(|n| n.to_string_lossy())
292        .unwrap_or_default();
293    if set.is_match(name.as_ref()) {
294        return true;
295    }
296    path.strip_prefix(root)
297        .map(|rel| set.is_match(rel))
298        .unwrap_or(false)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn default_excludes_match_names_anywhere() {
307        let set = build_excludes(&["*.log".into()]).unwrap();
308        let root = Path::new("/p");
309        assert!(is_excluded(Path::new("/p/a/node_modules"), root, &set));
310        assert!(is_excluded(Path::new("/p/x.log"), root, &set));
311        assert!(!is_excluded(Path::new("/p/src/main.rs"), root, &set));
312        assert!(!is_excluded(root, root, &set));
313    }
314}