use std::ffi::OsStr;
use std::fmt;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnsafePathError {
ParentDir,
Absolute,
ReservedName,
EscapesRoot,
}
impl fmt::Display for UnsafePathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let msg = match self {
Self::ParentDir => "path contains a parent-directory (`..`) component",
Self::Absolute => "path is absolute but only a relative path is allowed",
Self::ReservedName => "path contains a reserved device name",
Self::EscapesRoot => "path escapes the permitted root directory",
};
f.write_str(msg)
}
}
impl std::error::Error for UnsafePathError {}
impl From<UnsafePathError> for std::io::Error {
fn from(err: UnsafePathError) -> Self {
Self::new(std::io::ErrorKind::InvalidInput, err)
}
}
pub fn sanitize_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
sanitize(path.as_ref(), false)
}
pub fn sanitize_relative_path(path: impl AsRef<Path>) -> Result<PathBuf, UnsafePathError> {
sanitize(path.as_ref(), true)
}
fn sanitize(path: &Path, relative_only: bool) -> Result<PathBuf, UnsafePathError> {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
if relative_only {
return Err(UnsafePathError::Absolute);
}
out.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => return Err(UnsafePathError::ParentDir),
Component::Normal(name) => {
check_normal_component(name)?;
out.push(name);
}
}
}
Ok(out)
}
fn check_normal_component(name: &OsStr) -> Result<(), UnsafePathError> {
if !Path::new(name)
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
return Err(UnsafePathError::Absolute);
}
#[cfg(windows)]
if is_reserved_device_name(name) {
return Err(UnsafePathError::ReservedName);
}
Ok(())
}
#[must_use]
pub fn is_reserved_device_name(name: impl AsRef<OsStr>) -> bool {
let name = name.as_ref();
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
is_reserved_dos_name(|| name.encode_wide())
}
#[cfg(not(windows))]
{
let name = name.to_string_lossy();
is_reserved_dos_name(|| name.encode_utf16())
}
}
fn is_reserved_dos_name<F, I>(mut get_iter: F) -> bool
where
F: FnMut() -> I,
I: Iterator<Item = u16>,
{
const CON: [u16; 3] = [b'C' as u16, b'O' as u16, b'N' as u16];
const PRN: [u16; 3] = [b'P' as u16, b'R' as u16, b'N' as u16];
const AUX: [u16; 3] = [b'A' as u16, b'U' as u16, b'X' as u16];
const NUL: [u16; 3] = [b'N' as u16, b'U' as u16, b'L' as u16];
const CONIN: [u16; 6] = [
b'C' as u16,
b'O' as u16,
b'N' as u16,
b'I' as u16,
b'N' as u16,
b'$' as u16,
];
const CONOUT: [u16; 7] = [
b'C' as u16,
b'O' as u16,
b'N' as u16,
b'O' as u16,
b'U' as u16,
b'T' as u16,
b'$' as u16,
];
const COM: [u16; 3] = [b'C' as u16, b'O' as u16, b'M' as u16];
const LPT: [u16; 3] = [b'L' as u16, b'P' as u16, b'T' as u16];
const ZERO: u16 = b'0' as u16;
const NINE: u16 = b'9' as u16;
const SUPERSCRIPT_ONE: u16 = 0x00B9;
const SUPERSCRIPT_TWO: u16 = 0x00B2;
const SUPERSCRIPT_THREE: u16 = 0x00B3;
fn is_whitespace(c: u16) -> bool {
c <= 0x7F && ((c as u8).is_ascii_whitespace() || c == 0x000B)
}
let trimmed_len = get_iter()
.enumerate()
.take_while(|&(_idx, c)| c != b'.' as u16 && c != b':' as u16)
.filter(|&(_idx, c)| !is_whitespace(c))
.last()
.map(|(idx, _)| idx + 1)
.unwrap_or(0);
if trimmed_len > 7 {
return false;
}
let mut buf = [0u16; 7];
get_iter()
.take(trimmed_len)
.enumerate()
.for_each(|(i, c)| buf[i] = c);
for b in &mut buf {
if *b <= 0x7F {
*b = (*b as u8).to_ascii_uppercase() as u16;
}
if *b == SUPERSCRIPT_ONE {
*b = b'1' as u16;
}
if *b == SUPERSCRIPT_TWO {
*b = b'2' as u16;
}
if *b == SUPERSCRIPT_THREE {
*b = b'3' as u16;
}
}
let name = &buf[..trimmed_len];
if name == CON || name == PRN || name == AUX || name == NUL || name == CONIN || name == CONOUT {
return true;
}
if name.len() == 4 {
let prefix = &name[..3];
let suffix = name[3];
if (prefix == COM || prefix == LPT) && matches!(suffix, ZERO..=NINE) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn clean(path: &str) -> Result<PathBuf, UnsafePathError> {
sanitize_path(path)
}
#[test]
fn accepts_plain_relative_paths() {
assert_eq!(clean("a/b/c").unwrap(), PathBuf::from("a/b/c"));
assert_eq!(clean("foo.txt").unwrap(), PathBuf::from("foo.txt"));
assert_eq!(
clean("dir/file.bin").unwrap(),
PathBuf::from("dir/file.bin")
);
}
#[test]
fn drops_current_dir_components() {
assert_eq!(clean("./a/./b").unwrap(), PathBuf::from("a/b"));
assert_eq!(clean("a/./b/c").unwrap(), PathBuf::from("a/b/c"));
}
#[test]
fn rejects_parent_dir_traversal() {
for payload in [
"..",
"../",
"../etc/passwd",
"../../../../etc/passwd",
"foo/../bar",
"foo/../../bar",
"a/b/../../../c",
"dir/..",
] {
assert_eq!(
clean(payload),
Err(UnsafePathError::ParentDir),
"expected `{payload}` to be rejected as traversal",
);
}
}
#[test]
fn preserves_absolute_paths_without_a_root() {
#[cfg(unix)]
{
assert_eq!(clean("/etc/hosts").unwrap(), PathBuf::from("/etc/hosts"));
assert_eq!(
clean("/var/./www/index.html").unwrap(),
PathBuf::from("/var/www/index.html"),
);
assert_eq!(clean("/a/../b"), Err(UnsafePathError::ParentDir));
}
}
#[test]
fn rejects_absolute_paths_when_relative_only() {
#[cfg(unix)]
{
assert_eq!(
sanitize_relative_path("/etc/passwd"),
Err(UnsafePathError::Absolute),
);
assert_eq!(sanitize_relative_path("/"), Err(UnsafePathError::Absolute),);
}
assert_eq!(sanitize_relative_path("a/b").unwrap(), PathBuf::from("a/b"));
assert_eq!(
sanitize_relative_path("../a"),
Err(UnsafePathError::ParentDir),
);
}
#[test]
fn error_converts_to_invalid_input_io_error() {
let io_err: std::io::Error = UnsafePathError::ParentDir.into();
assert_eq!(io_err.kind(), std::io::ErrorKind::InvalidInput);
}
fn is_reserved(name: &str) -> bool {
is_reserved_device_name(name)
}
#[test]
fn detects_reserved_dos_names() {
for name in [
"CON", "con", "Con", "PRN", "AUX", "NUL", "nul", "COM1", "COM9", "com1", "LPT1",
"LPT9", "CONIN$", "CONOUT$", "CON.txt", "NUL.log", "com1.dat", "LPT3.bin", "CON ",
"CON.", "CON:", "COM0", "LPT0",
] {
assert!(is_reserved(name), "expected `{name}` to be reserved");
}
assert!(is_reserved("COM\u{00B9}"));
assert!(is_reserved("COM\u{00B2}"));
assert!(is_reserved("COM\u{00B3}"));
}
#[test]
fn allows_non_reserved_names() {
for name in [
"CONSOLE",
"COM",
"COM10",
"LPT",
"LPT10",
"NULL",
"file.txt",
"console.log",
"communication",
"prnt",
"auxiliary",
] {
assert!(!is_reserved(name), "expected `{name}` to be allowed");
}
}
}