1use 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#[derive(Debug, Clone)]
22pub struct SaveResult {
23 pub path: String,
25 pub bytes_written: u64,
27}
28
29#[derive(Debug, Error)]
31pub enum FileSaveError {
32 #[error("Path not allowed: {0}")]
34 PathNotAllowed(String),
35 #[error("IO error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("Save error: {0}")]
40 Other(String),
41}
42
43#[async_trait]
50pub trait FileSaver: Send + Sync {
51 async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError>;
54
55 async fn validate_path(&self, path: &str) -> Result<(), FileSaveError> {
58 let _ = path;
59 Ok(())
60 }
61}
62
63pub struct LocalFileSaver {
88 base_dir: Option<PathBuf>,
91}
92
93impl LocalFileSaver {
94 pub fn new(base_dir: Option<PathBuf>) -> Self {
96 Self { base_dir }
97 }
98
99 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 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(¤t_dir, name)?;
349 current_dir = open_child_dir_no_follow(¤t_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(¤t_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
497fn normalize_path(path: &Path) -> PathBuf {
499 let mut components = Vec::new();
500 for component in path.components() {
501 match component {
502 Component::ParentDir => {
503 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}