Skip to main content

fetchkit/
file_saver.rs

1//! File saving abstractions for FetchKit
2//!
3//! Consumers implement [`FileSaver`] to control where fetched bytes land:
4//! - CLI: writes to real filesystem ([`LocalFileSaver`])
5//! - Everruns: writes to session virtual filesystem
6//! - Tests: in-memory buffer
7
8use async_trait::async_trait;
9#[cfg(unix)]
10use std::ffi::{CString, OsStr};
11#[cfg(unix)]
12use std::io::Write;
13#[cfg(unix)]
14use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
15#[cfg(unix)]
16use std::os::unix::ffi::OsStrExt;
17use std::path::{Component, Path, PathBuf};
18use thiserror::Error;
19
20/// Result of a save operation
21#[derive(Debug, Clone)]
22pub struct SaveResult {
23    /// Canonical/normalized path where file was saved
24    pub path: String,
25    /// Bytes written
26    pub bytes_written: u64,
27}
28
29/// Errors that can occur during file save operations
30#[derive(Debug, Error)]
31pub enum FileSaveError {
32    /// Path is not allowed (traversal, outside base dir, etc.)
33    #[error("Path not allowed: {0}")]
34    PathNotAllowed(String),
35    /// IO error during save
36    #[error("IO error: {0}")]
37    Io(#[from] std::io::Error),
38    /// Other save error
39    #[error("Save error: {0}")]
40    Other(String),
41}
42
43/// Destination for saving fetched content to files.
44///
45/// Consumers implement this trait to control where bytes land:
46/// - CLI: writes to real filesystem ([`LocalFileSaver`])
47/// - Everruns: writes to session virtual filesystem
48/// - Tests: in-memory buffer
49#[async_trait]
50pub trait FileSaver: Send + Sync {
51    /// Save raw bytes to the given path.
52    /// Returns the canonical path where the file was written and bytes written.
53    async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError>;
54
55    /// Check if a path is writable / allowed before fetching.
56    /// Default: always allowed.
57    async fn validate_path(&self, path: &str) -> Result<(), FileSaveError> {
58        let _ = path;
59        Ok(())
60    }
61}
62
63/// Saves fetched content to the real filesystem.
64///
65/// Ships with fetchkit as a built-in implementation for CLI usage.
66///
67/// # Path resolution
68///
69/// - With `base_dir`: relative paths resolved against it; path traversal
70///   outside base_dir is rejected
71/// - Without `base_dir`: only absolute paths are accepted
72///
73/// Parent directories are created automatically.
74///
75/// # Examples
76///
77/// ```
78/// use fetchkit::LocalFileSaver;
79/// use std::path::PathBuf;
80///
81/// // Save relative to a base directory
82/// let saver = LocalFileSaver::new(Some(PathBuf::from("/tmp/downloads")));
83///
84/// // Save with absolute paths only
85/// let saver = LocalFileSaver::new(None);
86/// ```
87pub struct LocalFileSaver {
88    /// Optional base directory. Paths resolved relative to this.
89    /// If None, paths must be absolute.
90    base_dir: Option<PathBuf>,
91}
92
93impl LocalFileSaver {
94    /// Create a new LocalFileSaver with an optional base directory.
95    pub fn new(base_dir: Option<PathBuf>) -> Self {
96        Self { base_dir }
97    }
98
99    /// Resolve and validate a path, returning the normalized absolute path.
100    fn resolve_path(&self, path: &str) -> Result<PathBuf, FileSaveError> {
101        if path.is_empty() {
102            return Err(FileSaveError::PathNotAllowed(
103                "Path must name a file".into(),
104            ));
105        }
106
107        let input = PathBuf::from(path);
108
109        if let Some(base) = &self.base_dir {
110            let joined = if input.is_absolute() {
111                input
112            } else {
113                base.join(&input)
114            };
115            let normalized = normalize_path(&joined);
116            let normalized_base = normalize_path(base);
117
118            if !normalized.starts_with(&normalized_base) {
119                return Err(FileSaveError::PathNotAllowed(format!(
120                    "Path escapes base directory: {}",
121                    path
122                )));
123            }
124            Ok(normalized)
125        } else {
126            if !input.is_absolute() {
127                return Err(FileSaveError::PathNotAllowed(
128                    "Path must be absolute when no base_dir is set".into(),
129                ));
130            }
131            Ok(normalize_path(&input))
132        }
133    }
134
135    #[cfg(not(unix))]
136    async fn canonicalize_base_dir(&self, base: &Path) -> Result<PathBuf, FileSaveError> {
137        tokio::fs::create_dir_all(base).await?;
138
139        let meta = tokio::fs::symlink_metadata(base).await?;
140        if meta.file_type().is_symlink() {
141            return Err(FileSaveError::PathNotAllowed(
142                "Base directory must not be a symlink".into(),
143            ));
144        }
145        if !meta.is_dir() {
146            return Err(FileSaveError::PathNotAllowed(
147                "Base directory must be a directory".into(),
148            ));
149        }
150
151        Ok(tokio::fs::canonicalize(base).await?)
152    }
153
154    #[cfg(not(unix))]
155    async fn prepare_parent_dir(&self, resolved: &Path) -> Result<PathBuf, FileSaveError> {
156        let Some(base) = &self.base_dir else {
157            return Ok(resolved
158                .parent()
159                .ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?
160                .to_path_buf());
161        };
162
163        let normalized_base = normalize_path(base);
164        let relative = resolved
165            .strip_prefix(&normalized_base)
166            .map_err(|_| FileSaveError::PathNotAllowed("Path escapes base directory".into()))?;
167        let canonical_base = self.canonicalize_base_dir(base).await?;
168        let mut current = canonical_base.clone();
169
170        for component in relative
171            .parent()
172            .unwrap_or_else(|| Path::new(""))
173            .components()
174        {
175            let Component::Normal(name) = component else {
176                return Err(FileSaveError::PathNotAllowed(format!(
177                    "Unsupported path component in save path: {}",
178                    resolved.display()
179                )));
180            };
181
182            let candidate = current.join(name);
183            let meta = match tokio::fs::symlink_metadata(&candidate).await {
184                Ok(meta) => meta,
185                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
186                    if let Err(create_err) = tokio::fs::create_dir(&candidate).await {
187                        if create_err.kind() != std::io::ErrorKind::AlreadyExists {
188                            return Err(create_err.into());
189                        }
190                    }
191                    tokio::fs::symlink_metadata(&candidate).await?
192                }
193                Err(err) => return Err(err.into()),
194            };
195
196            if meta.file_type().is_symlink() {
197                return Err(FileSaveError::PathNotAllowed(format!(
198                    "Path traverses symlink: {}",
199                    candidate.display()
200                )));
201            }
202            if !meta.is_dir() {
203                return Err(FileSaveError::PathNotAllowed(format!(
204                    "Parent path is not a directory: {}",
205                    candidate.display()
206                )));
207            }
208
209            let canonical_candidate = tokio::fs::canonicalize(&candidate).await?;
210            if !canonical_candidate.starts_with(&canonical_base) {
211                return Err(FileSaveError::PathNotAllowed(format!(
212                    "Path escapes base directory via symlink: {}",
213                    candidate.display()
214                )));
215            }
216            current = canonical_candidate;
217        }
218
219        Ok(current)
220    }
221
222    #[cfg(unix)]
223    async fn write_resolved_path(
224        &self,
225        resolved: PathBuf,
226        bytes: &[u8],
227    ) -> Result<PathBuf, FileSaveError> {
228        let bytes = bytes.to_vec();
229        let task = if let Some(base_dir) = &self.base_dir {
230            let normalized_base = normalize_path(base_dir);
231            let relative = resolved
232                .strip_prefix(&normalized_base)
233                .map_err(|_| FileSaveError::PathNotAllowed("Path escapes base directory".into()))?
234                .to_path_buf();
235            let base_dir = base_dir.clone();
236            tokio::task::spawn_blocking(move || {
237                save_under_base_no_follow(&base_dir, &relative, &bytes)
238            })
239        } else {
240            tokio::task::spawn_blocking(move || save_absolute_no_follow(&resolved, &bytes))
241        };
242
243        task.await
244            .map_err(|err| FileSaveError::Other(format!("File save task failed: {err}")))?
245    }
246
247    #[cfg(not(unix))]
248    async fn write_resolved_path(
249        &self,
250        resolved: PathBuf,
251        bytes: &[u8],
252    ) -> Result<PathBuf, FileSaveError> {
253        let file_name = resolved
254            .file_name()
255            .ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?;
256        let parent_dir = self.prepare_parent_dir(&resolved).await?;
257        let final_path = parent_dir.join(file_name);
258
259        if self.base_dir.is_none() {
260            if let Some(parent) = final_path.parent() {
261                tokio::fs::create_dir_all(parent).await?;
262            }
263        }
264
265        match tokio::fs::symlink_metadata(&final_path).await {
266            Ok(meta) if meta.file_type().is_symlink() => {
267                return Err(FileSaveError::PathNotAllowed(format!(
268                    "Refusing to write through symlink: {}",
269                    final_path.display()
270                )));
271            }
272            Ok(_) => {}
273            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
274            Err(err) => return Err(err.into()),
275        }
276
277        tokio::fs::write(&final_path, bytes).await?;
278        Ok(final_path)
279    }
280}
281
282#[async_trait]
283impl FileSaver for LocalFileSaver {
284    async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError> {
285        let resolved = self.resolve_path(path)?;
286        if let Some(base_dir) = &self.base_dir {
287            if resolved == normalize_path(base_dir) {
288                return Err(FileSaveError::PathNotAllowed(
289                    "Path must name a file".into(),
290                ));
291            }
292        }
293        resolved
294            .file_name()
295            .ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?;
296
297        let final_path = self.write_resolved_path(resolved, bytes).await?;
298
299        Ok(SaveResult {
300            path: final_path.to_string_lossy().to_string(),
301            bytes_written: bytes.len() as u64,
302        })
303    }
304
305    async fn validate_path(&self, path: &str) -> Result<(), FileSaveError> {
306        self.resolve_path(path)?;
307        Ok(())
308    }
309}
310
311#[cfg(unix)]
312fn save_under_base_no_follow(
313    base: &Path,
314    relative: &Path,
315    bytes: &[u8],
316) -> Result<PathBuf, FileSaveError> {
317    // Keep traversal and final creation anchored to directory descriptors so
318    // attacker-controlled symlink swaps cannot redirect a later path open.
319    std::fs::create_dir_all(base)?;
320
321    let meta = std::fs::symlink_metadata(base)?;
322    if meta.file_type().is_symlink() {
323        return Err(FileSaveError::PathNotAllowed(
324            "Base directory must not be a symlink".into(),
325        ));
326    }
327    if !meta.is_dir() {
328        return Err(FileSaveError::PathNotAllowed(
329            "Base directory must be a directory".into(),
330        ));
331    }
332
333    let canonical_base = std::fs::canonicalize(base)?;
334    let mut current_dir = open_dir_no_follow(&canonical_base)?;
335
336    for component in relative
337        .parent()
338        .unwrap_or_else(|| Path::new(""))
339        .components()
340    {
341        let Component::Normal(name) = component else {
342            return Err(FileSaveError::PathNotAllowed(format!(
343                "Unsupported path component in save path: {}",
344                relative.display()
345            )));
346        };
347
348        mkdirat_if_missing(&current_dir, name)?;
349        current_dir = open_child_dir_no_follow(&current_dir, name)?;
350    }
351
352    let file_name = relative
353        .file_name()
354        .ok_or_else(|| FileSaveError::PathNotAllowed("Path must name a file".into()))?;
355    let final_path = canonical_base.join(relative);
356    let mut file = open_child_file_no_follow(&current_dir, file_name, &final_path)?;
357    file.write_all(bytes)?;
358
359    Ok(final_path)
360}
361
362#[cfg(unix)]
363fn save_absolute_no_follow(path: &Path, bytes: &[u8]) -> Result<PathBuf, FileSaveError> {
364    if let Some(parent) = path.parent() {
365        std::fs::create_dir_all(parent)?;
366    }
367
368    let mut file = open_path_no_follow(path)?;
369    file.write_all(bytes)?;
370    Ok(path.to_path_buf())
371}
372
373#[cfg(unix)]
374fn open_dir_no_follow(path: &Path) -> Result<OwnedFd, FileSaveError> {
375    let path = cstring_path(path)?;
376    let fd = unsafe {
377        libc::open(
378            path.as_ptr(),
379            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
380        )
381    };
382    owned_fd_from_result(fd, "Refusing to traverse symlink")
383}
384
385#[cfg(unix)]
386fn open_child_dir_no_follow(parent: &OwnedFd, name: &OsStr) -> Result<OwnedFd, FileSaveError> {
387    let name = cstring_component(name)?;
388    let fd = unsafe {
389        libc::openat(
390            parent.as_raw_fd(),
391            name.as_ptr(),
392            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
393        )
394    };
395    owned_fd_from_result(fd, "Refusing to traverse symlink")
396}
397
398#[cfg(unix)]
399fn open_child_file_no_follow(
400    parent: &OwnedFd,
401    name: &OsStr,
402    path_for_error: &Path,
403) -> Result<std::fs::File, FileSaveError> {
404    let name = cstring_component(name)?;
405    let fd = unsafe {
406        libc::openat(
407            parent.as_raw_fd(),
408            name.as_ptr(),
409            libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
410            0o666,
411        )
412    };
413    file_from_result(
414        fd,
415        format!(
416            "Refusing to write through symlink: {}",
417            path_for_error.display()
418        ),
419    )
420}
421
422#[cfg(unix)]
423fn open_path_no_follow(path: &Path) -> Result<std::fs::File, FileSaveError> {
424    let c_path = cstring_path(path)?;
425    let fd = unsafe {
426        libc::open(
427            c_path.as_ptr(),
428            libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
429            0o666,
430        )
431    };
432    file_from_result(
433        fd,
434        format!("Refusing to write through symlink: {}", path.display()),
435    )
436}
437
438#[cfg(unix)]
439fn mkdirat_if_missing(parent: &OwnedFd, name: &OsStr) -> Result<(), FileSaveError> {
440    let name = cstring_component(name)?;
441    let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o755) };
442    if result == 0 {
443        return Ok(());
444    }
445
446    let err = std::io::Error::last_os_error();
447    if err.kind() == std::io::ErrorKind::AlreadyExists {
448        Ok(())
449    } else {
450        Err(err.into())
451    }
452}
453
454#[cfg(unix)]
455fn owned_fd_from_result(fd: libc::c_int, symlink_message: &str) -> Result<OwnedFd, FileSaveError> {
456    if fd >= 0 {
457        Ok(unsafe { OwnedFd::from_raw_fd(fd) })
458    } else {
459        Err(open_error(symlink_message.to_string()))
460    }
461}
462
463#[cfg(unix)]
464fn file_from_result(
465    fd: libc::c_int,
466    symlink_message: String,
467) -> Result<std::fs::File, FileSaveError> {
468    if fd >= 0 {
469        Ok(unsafe { std::fs::File::from_raw_fd(fd) })
470    } else {
471        Err(open_error(symlink_message))
472    }
473}
474
475#[cfg(unix)]
476fn open_error(symlink_message: String) -> FileSaveError {
477    let err = std::io::Error::last_os_error();
478    if err.raw_os_error() == Some(libc::ELOOP) {
479        FileSaveError::PathNotAllowed(symlink_message)
480    } else {
481        FileSaveError::Io(err)
482    }
483}
484
485#[cfg(unix)]
486fn cstring_path(path: &Path) -> Result<CString, FileSaveError> {
487    CString::new(path.as_os_str().as_bytes())
488        .map_err(|_| FileSaveError::PathNotAllowed("Path contains NUL byte".into()))
489}
490
491#[cfg(unix)]
492fn cstring_component(component: &OsStr) -> Result<CString, FileSaveError> {
493    CString::new(component.as_bytes())
494        .map_err(|_| FileSaveError::PathNotAllowed("Path contains NUL byte".into()))
495}
496
497/// Lexically normalize a path by resolving `.` and `..` components.
498fn normalize_path(path: &Path) -> PathBuf {
499    let mut components = Vec::new();
500    for component in path.components() {
501        match component {
502            Component::ParentDir => {
503                // Only pop if the last component is a normal component
504                if matches!(components.last(), Some(Component::Normal(_))) {
505                    components.pop();
506                } else {
507                    components.push(component);
508                }
509            }
510            Component::CurDir => {}
511            c => components.push(c),
512        }
513    }
514    components.iter().collect()
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::path::PathBuf;
521
522    #[test]
523    fn test_normalize_path() {
524        assert_eq!(
525            normalize_path(Path::new("/a/b/../c")),
526            PathBuf::from("/a/c")
527        );
528        assert_eq!(
529            normalize_path(Path::new("/a/b/./c")),
530            PathBuf::from("/a/b/c")
531        );
532        assert_eq!(
533            normalize_path(Path::new("/a/b/c/../..")),
534            PathBuf::from("/a")
535        );
536    }
537
538    #[test]
539    fn test_local_file_saver_resolve_relative() {
540        let saver = LocalFileSaver::new(Some(PathBuf::from("/tmp/downloads")));
541        let resolved = saver.resolve_path("file.txt").unwrap();
542        assert_eq!(resolved, PathBuf::from("/tmp/downloads/file.txt"));
543    }
544
545    #[test]
546    fn test_local_file_saver_resolve_subdirectory() {
547        let saver = LocalFileSaver::new(Some(PathBuf::from("/tmp/downloads")));
548        let resolved = saver.resolve_path("sub/dir/file.txt").unwrap();
549        assert_eq!(resolved, PathBuf::from("/tmp/downloads/sub/dir/file.txt"));
550    }
551
552    #[test]
553    fn test_local_file_saver_reject_traversal() {
554        let saver = LocalFileSaver::new(Some(PathBuf::from("/tmp/downloads")));
555        let result = saver.resolve_path("../../etc/passwd");
556        assert!(result.is_err());
557        assert!(matches!(
558            result.unwrap_err(),
559            FileSaveError::PathNotAllowed(_)
560        ));
561    }
562
563    #[test]
564    fn test_local_file_saver_reject_traversal_absolute() {
565        let saver = LocalFileSaver::new(Some(PathBuf::from("/tmp/downloads")));
566        let result = saver.resolve_path("/etc/passwd");
567        assert!(result.is_err());
568    }
569
570    #[test]
571    fn test_local_file_saver_no_base_requires_absolute() {
572        let saver = LocalFileSaver::new(None);
573        let result = saver.resolve_path("relative.txt");
574        assert!(result.is_err());
575        assert!(matches!(
576            result.unwrap_err(),
577            FileSaveError::PathNotAllowed(_)
578        ));
579    }
580
581    #[test]
582    fn test_local_file_saver_no_base_accepts_absolute() {
583        let saver = LocalFileSaver::new(None);
584        let resolved = saver.resolve_path("/tmp/file.txt").unwrap();
585        assert_eq!(resolved, PathBuf::from("/tmp/file.txt"));
586    }
587
588    #[tokio::test]
589    async fn test_local_file_saver_save_and_read() {
590        let dir = tempfile::tempdir().unwrap();
591        let saver = LocalFileSaver::new(Some(dir.path().to_path_buf()));
592
593        let result = saver.save("test.txt", b"hello world").await.unwrap();
594        assert_eq!(result.bytes_written, 11);
595        assert!(result.path.ends_with("test.txt"));
596
597        let content = tokio::fs::read_to_string(dir.path().join("test.txt"))
598            .await
599            .unwrap();
600        assert_eq!(content, "hello world");
601    }
602
603    #[tokio::test]
604    async fn test_local_file_saver_creates_parent_dirs() {
605        let dir = tempfile::tempdir().unwrap();
606        let saver = LocalFileSaver::new(Some(dir.path().to_path_buf()));
607
608        let result = saver
609            .save("sub/dir/file.bin", &[0xFF, 0x00, 0xAB])
610            .await
611            .unwrap();
612        assert_eq!(result.bytes_written, 3);
613
614        let content = tokio::fs::read(dir.path().join("sub/dir/file.bin"))
615            .await
616            .unwrap();
617        assert_eq!(content, vec![0xFF, 0x00, 0xAB]);
618    }
619
620    #[tokio::test]
621    async fn test_local_file_saver_validate_path() {
622        let dir = tempfile::tempdir().unwrap();
623        let saver = LocalFileSaver::new(Some(dir.path().to_path_buf()));
624
625        assert!(saver.validate_path("safe.txt").await.is_ok());
626        assert!(saver.validate_path("sub/dir/safe.txt").await.is_ok());
627        assert!(saver.validate_path("../../escape.txt").await.is_err());
628    }
629}