use std::fs;
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UnsafePathError {
#[error("empty paths are not allowed")]
Empty,
#[error("absolute paths are not allowed")]
Absolute,
#[error("path traversal ('..') is not allowed")]
Traversal,
}
pub fn move_dir_contents(source_dir: &Path, out: &Path) -> io::Result<()> {
for entry in fs::read_dir(source_dir)? {
let entry = entry?;
let source_path = entry.path();
let target_path = out.join(entry.file_name());
remove_existing_path(&target_path)?;
fs::rename(source_path, target_path)?;
}
Ok(())
}
pub fn single_child_dir_or_self(path: &Path) -> io::Result<PathBuf> {
let entries = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
if entries.len() == 1 {
let only_entry_path = entries[0].path();
if only_entry_path.is_dir() {
return Ok(only_entry_path);
}
}
Ok(path.to_path_buf())
}
pub fn remove_existing_path(path: &Path) -> io::Result<()> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
if metadata.is_dir() && !metadata.file_type().is_symlink() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
}
pub fn safe_join(base: &Path, rel: &Path) -> Result<PathBuf, UnsafePathError> {
if rel.as_os_str().is_empty() {
return Err(UnsafePathError::Empty);
}
if rel.is_absolute() {
return Err(UnsafePathError::Absolute);
}
let mut clean = PathBuf::new();
for component in rel.components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir => return Err(UnsafePathError::Traversal),
Component::RootDir | Component::Prefix(_) => {
return Err(UnsafePathError::Absolute);
}
}
}
if clean.as_os_str().is_empty() {
return Err(UnsafePathError::Empty);
}
Ok(base.join(clean))
}
pub fn write_file_from_reader(path: &Path, mode: u32, reader: &mut dyn Read) -> io::Result<()> {
let mut file = fs::File::create(path)?;
io::copy(reader, &mut file)?;
set_permissions(path, mode)?;
Ok(())
}
pub fn set_permissions(path: &Path, mode: u32) -> io::Result<()> {
fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
use std::io::Cursor;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[test]
fn move_dir_contents_should_move_files_and_directories_into_output() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source");
let out = dir.path().join("out");
let nested = source.join("nested");
fs::create_dir_all(&nested).unwrap();
fs::create_dir(&out).unwrap();
fs::write(source.join("root.txt"), "hello").unwrap();
fs::write(nested.join("inner.txt"), "world").unwrap();
move_dir_contents(&source, &out).unwrap();
assert_eq!(fs::read_to_string(out.join("root.txt")).unwrap(), "hello");
assert_eq!(
fs::read_to_string(out.join("nested/inner.txt")).unwrap(),
"world"
);
assert!(fs::read_dir(&source).unwrap().next().is_none());
}
#[test]
fn move_dir_contents_should_overwrite_existing_targets() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("source");
let out = dir.path().join("out");
fs::create_dir_all(source.join("nested")).unwrap();
fs::create_dir_all(out.join("nested")).unwrap();
fs::write(source.join("root.txt"), "new file").unwrap();
fs::write(source.join("nested/inner.txt"), "new nested").unwrap();
fs::write(out.join("root.txt"), "old file").unwrap();
fs::write(out.join("nested/old.txt"), "old nested").unwrap();
move_dir_contents(&source, &out).unwrap();
assert_eq!(
fs::read_to_string(out.join("root.txt")).unwrap(),
"new file"
);
assert_eq!(
fs::read_to_string(out.join("nested/inner.txt")).unwrap(),
"new nested"
);
assert!(!out.join("nested/old.txt").exists());
}
#[test]
fn remove_existing_path_should_remove_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("file.txt");
fs::write(&path, "hello").unwrap();
remove_existing_path(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn remove_existing_path_should_remove_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested");
fs::create_dir(&path).unwrap();
fs::write(path.join("inner.txt"), "hello").unwrap();
remove_existing_path(&path).unwrap();
assert!(!path.exists());
}
#[test]
fn remove_existing_path_should_ignore_missing_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("missing");
let actual = remove_existing_path(&path);
assert!(actual.is_ok());
}
#[test]
fn write_file_from_reader_should_write_contents() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("file.txt");
let mut input = Cursor::new("hello");
write_file_from_reader(&path, 0o644, &mut input).unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "hello");
}
#[cfg(unix)]
#[test]
fn write_file_from_reader_should_set_permissions() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("file.txt");
let mut input = Cursor::new("hello");
write_file_from_reader(&path, 0o600, &mut input).unwrap();
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn set_permissions_should_mask_to_permission_bits() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("file.txt");
fs::write(&path, "hello").unwrap();
set_permissions(&path, 0o100755).unwrap();
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o755
);
}
#[test]
fn safe_join_should_return_unsafe_path_error_when_empty_path_given() {
let base = PathBuf::from("/tmp");
let empty = PathBuf::from("");
let actual = safe_join(&base, &empty);
assert!(actual.is_err());
assert!(matches!(actual.err().unwrap(), UnsafePathError::Empty))
}
#[test]
fn safe_join_should_return_unsafe_path_error_when_absolute_path_given() {
let base = PathBuf::from("/tmp");
let empty = PathBuf::from("/absolute");
let actual = safe_join(&base, &empty);
assert!(actual.is_err());
assert!(matches!(actual.err().unwrap(), UnsafePathError::Absolute))
}
#[test]
fn safe_join_should_return_unsafe_path_error_when_nested_traversel_path_given() {
let base = PathBuf::from("/tmp");
let empty = PathBuf::from("nested/../../escape");
let actual = safe_join(&base, &empty);
assert!(actual.is_err());
assert!(matches!(actual.err().unwrap(), UnsafePathError::Traversal));
}
#[test]
fn safe_join_should_join_two_paths() {
let base = PathBuf::from("/tmp");
let empty = PathBuf::from("foo/bar");
let actual = safe_join(&base, &empty).unwrap();
assert_eq!(actual, PathBuf::from("/tmp/foo/bar"))
}
#[test]
fn single_child_dir_or_self_should_return_only_child_directory() {
let dir = TempDir::new().unwrap();
let child = dir.path().join("package");
fs::create_dir(&child).unwrap();
let actual = single_child_dir_or_self(dir.path()).unwrap();
assert_eq!(actual, child);
}
#[test]
fn single_child_dir_or_self_should_return_self_for_multiple_entries() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("package")).unwrap();
fs::write(dir.path().join("README.txt"), "hello").unwrap();
let actual = single_child_dir_or_self(dir.path()).unwrap();
assert_eq!(actual, dir.path());
}
#[test]
fn single_child_dir_or_self_should_return_self_for_single_file() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("README.txt"), "hello").unwrap();
let actual = single_child_dir_or_self(dir.path()).unwrap();
assert_eq!(actual, dir.path());
}
}