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;
14use crate::text::shell_quote;
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(Error::Invalid(format!(
118                "{total} bytes exceeds the transfer limit of {} bytes",
119                self.limits.max_transfer_bytes
120            )));
121        }
122
123        // The guard runs over the whole plan before the first byte moves, so a
124        // refused file never leaves a half-uploaded tree behind.
125        for (path, _) in &plan {
126            let bytes = std::fs::read(path)?;
127            self.guard.ensure_upload(path, &bytes)?;
128        }
129
130        // Create the directory skeleton in one command.
131        let mut dirs: Vec<String> = plan
132            .iter()
133            .filter_map(|(_, dest)| {
134                Path::new(dest)
135                    .parent()
136                    .map(|p| p.to_string_lossy().into_owned())
137            })
138            .collect();
139        dirs.push(remote_base.clone());
140        dirs.sort();
141        dirs.dedup();
142        let mkdir = format!(
143            "mkdir -p {}",
144            dirs.iter()
145                .map(|d| shell_quote(d))
146                .collect::<Vec<_>>()
147                .join(" ")
148        );
149        let out = self.remote.exec(&mkdir, None, Some(60)).await?;
150        if out.exit_code != 0 {
151            return Err(Error::Ssh(format!(
152                "mkdir -p failed: {}",
153                String::from_utf8_lossy(&out.stderr).trim()
154            )));
155        }
156
157        for (path, dest) in &plan {
158            let bytes = std::fs::read(path)?;
159            self.remote.write_file(dest, &bytes).await?;
160            report.files += 1;
161            report.bytes += bytes.len() as u64;
162        }
163        report.destination = remote_base;
164        Ok(report)
165    }
166
167    async fn send_file(
168        &self,
169        local: &Path,
170        dest: &str,
171        report: &mut TransferReport,
172    ) -> Result<u64> {
173        let meta = std::fs::metadata(local)?;
174        if meta.len() > self.limits.max_transfer_bytes {
175            return Err(Error::Invalid(format!(
176                "{} bytes exceeds the transfer limit of {} bytes",
177                meta.len(),
178                self.limits.max_transfer_bytes
179            )));
180        }
181        let bytes = std::fs::read(local)?;
182        self.guard.ensure_upload(local, &bytes)?;
183        self.remote.write_file(dest, &bytes).await?;
184        report.files += 1;
185        Ok(bytes.len() as u64)
186    }
187
188    /// Download a remote file or directory into the allowlist. A file lands
189    /// at `local_path` (or inside it when that is an existing directory or
190    /// ends with `/`); a directory's contents land inside `local_path`.
191    pub async fn download(&self, remote_raw: &str, local_raw: &str) -> Result<TransferReport> {
192        let remote_path = self.remote.resolve(remote_raw).await?;
193        let local = self.local.resolve_for_write(local_raw)?;
194        let mut report = TransferReport::default();
195        let max = self.limits.max_transfer_bytes as usize;
196
197        match self.remote.exists(&remote_path).await? {
198            None => Err(Error::Invalid(format!(
199                "`{remote_path}` does not exist on the remote"
200            ))),
201            Some(false) => {
202                let dest = if local.is_dir() || local_raw.ends_with('/') {
203                    local.join(Path::new(&remote_path).file_name().unwrap_or_default())
204                } else {
205                    local
206                };
207                let dest = self.local_target(&dest)?;
208                let (bytes, len) = self.remote.read_file(&remote_path, max).await?;
209                if len as usize > max {
210                    return Err(Error::Invalid(format!(
211                        "`{remote_path}` exceeds the transfer limit"
212                    )));
213                }
214                if let Some(parent) = dest.parent() {
215                    std::fs::create_dir_all(parent)?;
216                }
217                std::fs::write(&dest, &bytes)?;
218                report.files = 1;
219                report.bytes = bytes.len() as u64;
220                report.destination = dest.display().to_string();
221                Ok(report)
222            }
223            Some(true) => {
224                let list = self
225                    .remote
226                    .exec(
227                        &format!("find {} -type f -print", shell_quote(&remote_path)),
228                        None,
229                        Some(120),
230                    )
231                    .await?;
232                if list.exit_code != 0 {
233                    return Err(Error::Ssh(
234                        String::from_utf8_lossy(&list.stderr).trim().to_string(),
235                    ));
236                }
237                let files: Vec<String> = String::from_utf8_lossy(&list.stdout)
238                    .lines()
239                    .map(str::to_string)
240                    .collect();
241                if files.len() > self.limits.max_transfer_files {
242                    return Err(Error::Invalid(format!(
243                        "more than {} files; narrow the download",
244                        self.limits.max_transfer_files
245                    )));
246                }
247                // Names come from the remote, so every destination is checked
248                // like a model-supplied path before anything is written:
249                // no `..`, no escaping through a local symlink, nothing an
250                // editor or agent would trust.
251                let mut plan: Vec<(String, PathBuf)> = Vec::with_capacity(files.len());
252                for f in files {
253                    let rel = f
254                        .strip_prefix(&remote_path)
255                        .unwrap_or(&f)
256                        .trim_start_matches('/');
257                    if rel.is_empty() || rel.split('/').any(|seg| seg == "..") {
258                        report.skipped.push(f.clone());
259                        continue;
260                    }
261                    let dest = self.local_target(&local.join(rel))?;
262                    plan.push((f, dest));
263                }
264                for (f, dest) in plan {
265                    let (bytes, len) = self.remote.read_file(&f, max).await?;
266                    if report.bytes + len > self.limits.max_transfer_bytes {
267                        return Err(Error::Invalid("transfer limit exceeded".into()));
268                    }
269                    if let Some(parent) = dest.parent() {
270                        std::fs::create_dir_all(parent)?;
271                    }
272                    std::fs::write(&dest, &bytes)?;
273                    report.files += 1;
274                    report.bytes += bytes.len() as u64;
275                }
276                report.destination = local.display().to_string();
277                Ok(report)
278            }
279        }
280    }
281}
282
283impl Transfer<'_> {
284    /// Re-check a download destination: it must still resolve inside the
285    /// allowlist (a symlink under the root could point elsewhere) and its
286    /// path relative to that root must pass the guard.
287    fn local_target(&self, dest: &Path) -> Result<PathBuf> {
288        let dest = self.local.resolve_for_write(&dest.to_string_lossy())?;
289        let rel = self
290            .local
291            .roots()
292            .iter()
293            .find_map(|r| dest.strip_prefix(r).ok())
294            .unwrap_or(&dest);
295        self.guard.ensure_download(rel)?;
296        Ok(dest)
297    }
298}
299
300fn build_excludes(extra: &[String]) -> Result<GlobSet> {
301    let mut b = GlobSetBuilder::new();
302    for g in DEFAULT_EXCLUDES
303        .iter()
304        .map(|s| s.to_string())
305        .chain(extra.iter().cloned())
306    {
307        b.add(Glob::new(&g).map_err(|e| Error::Invalid(format!("exclude `{g}`: {e}")))?);
308    }
309    b.build().map_err(|e| Error::Invalid(e.to_string()))
310}
311
312fn is_excluded(path: &Path, root: &Path, set: &GlobSet) -> bool {
313    if path == root {
314        return false;
315    }
316    let name = path
317        .file_name()
318        .map(|n| n.to_string_lossy())
319        .unwrap_or_default();
320    if set.is_match(name.as_ref()) {
321        return true;
322    }
323    path.strip_prefix(root)
324        .map(|rel| set.is_match(rel))
325        .unwrap_or(false)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn default_excludes_match_names_anywhere() {
334        let set = build_excludes(&["*.log".into()]).unwrap();
335        let root = Path::new("/p");
336        assert!(is_excluded(Path::new("/p/a/node_modules"), root, &set));
337        assert!(is_excluded(Path::new("/p/x.log"), root, &set));
338        assert!(!is_excluded(Path::new("/p/src/main.rs"), root, &set));
339        assert!(!is_excluded(root, root, &set));
340    }
341}