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(Error::Invalid(format!(
117                "{total} bytes exceeds the transfer limit of {} bytes",
118                self.limits.max_transfer_bytes
119            )));
120        }
121
122        // The guard runs over the whole plan before the first byte moves, so a
123        // refused file never leaves a half-uploaded tree behind.
124        for (path, _) in &plan {
125            let bytes = std::fs::read(path)?;
126            self.guard.ensure_upload(path, &bytes)?;
127        }
128
129        // `write_file` creates parents as it goes; the remote remembers
130        // which directories it has seen, so a deep tree costs one check per
131        // directory, not per file.
132        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    /// Download a remote file or directory into the allowlist. A file lands
165    /// at `local_path` (or inside it when that is an existing directory or
166    /// ends with `/`); a directory's contents land inside `local_path`.
167    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                    // The remote path may use `\`, which is an ordinary
180                    // character to the local `Path` on POSIX.
181                    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                // Names come from the remote, so every destination is checked
217                // like a model-supplied path before anything is written:
218                // no `..`, no escaping through a local symlink, nothing an
219                // editor or agent would trust.
220                let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
221                for f in files {
222                    // Native remote separators become `/` locally.
223                    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    /// Re-check a download destination: it must still resolve inside the
256    /// allowlist (a symlink under the root could point elsewhere) and its
257    /// path relative to that root must pass the guard.
258    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}