treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58342 additions and 54241 deletions
|
|
@ -37,72 +37,81 @@ use crate::error::{PinakesError, Result};
|
|||
///
|
||||
/// ```no_run
|
||||
/// use std::path::PathBuf;
|
||||
///
|
||||
/// use pinakes_core::path_validation::validate_path;
|
||||
///
|
||||
/// let allowed_roots = vec![PathBuf::from("/media"), PathBuf::from("/home/user/documents")];
|
||||
/// let allowed_roots = vec![
|
||||
/// PathBuf::from("/media"),
|
||||
/// PathBuf::from("/home/user/documents"),
|
||||
/// ];
|
||||
/// let path = PathBuf::from("/media/music/song.mp3");
|
||||
///
|
||||
/// let validated = validate_path(&path, &allowed_roots).unwrap();
|
||||
/// ```
|
||||
pub fn validate_path(path: &Path, allowed_roots: &[PathBuf]) -> Result<PathBuf> {
|
||||
// Handle the case where no roots are configured
|
||||
if allowed_roots.is_empty() {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"no allowed roots configured".to_string(),
|
||||
));
|
||||
}
|
||||
pub fn validate_path(
|
||||
path: &Path,
|
||||
allowed_roots: &[PathBuf],
|
||||
) -> Result<PathBuf> {
|
||||
// Handle the case where no roots are configured
|
||||
if allowed_roots.is_empty() {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"no allowed roots configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// First check if the path exists
|
||||
if !path.exists() {
|
||||
return Err(PinakesError::PathNotAllowed(format!(
|
||||
"path does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
// First check if the path exists
|
||||
if !path.exists() {
|
||||
return Err(PinakesError::PathNotAllowed(format!(
|
||||
"path does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Canonicalize to resolve symlinks and relative components
|
||||
let canonical = path.canonicalize().map_err(|e| {
|
||||
PinakesError::PathNotAllowed(format!(
|
||||
"failed to canonicalize path {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
// Canonicalize to resolve symlinks and relative components
|
||||
let canonical = path.canonicalize().map_err(|e| {
|
||||
PinakesError::PathNotAllowed(format!(
|
||||
"failed to canonicalize path {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Check if the canonical path is within any allowed root
|
||||
let canonical_roots: Vec<PathBuf> = allowed_roots
|
||||
.iter()
|
||||
.filter_map(|root| root.canonicalize().ok())
|
||||
.collect();
|
||||
// Check if the canonical path is within any allowed root
|
||||
let canonical_roots: Vec<PathBuf> = allowed_roots
|
||||
.iter()
|
||||
.filter_map(|root| root.canonicalize().ok())
|
||||
.collect();
|
||||
|
||||
if canonical_roots.is_empty() {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"no accessible allowed roots".to_string(),
|
||||
));
|
||||
}
|
||||
if canonical_roots.is_empty() {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"no accessible allowed roots".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_allowed = canonical_roots
|
||||
.iter()
|
||||
.any(|root| canonical.starts_with(root));
|
||||
let is_allowed = canonical_roots
|
||||
.iter()
|
||||
.any(|root| canonical.starts_with(root));
|
||||
|
||||
if is_allowed {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err(PinakesError::PathNotAllowed(format!(
|
||||
"path {} is outside allowed roots",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
if is_allowed {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err(PinakesError::PathNotAllowed(format!(
|
||||
"path {} is outside allowed roots",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a path relative to a single root directory.
|
||||
///
|
||||
/// This is a convenience wrapper for `validate_path` when you only have one root.
|
||||
/// This is a convenience wrapper for `validate_path` when you only have one
|
||||
/// root.
|
||||
pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
||||
validate_path(path, &[root.to_path_buf()])
|
||||
validate_path(path, &[root.to_path_buf()])
|
||||
}
|
||||
|
||||
/// Checks if a path appears to contain traversal sequences without canonicalizing.
|
||||
/// Checks if a path appears to contain traversal sequences without
|
||||
/// canonicalizing.
|
||||
///
|
||||
/// This is a quick pre-check that can reject obviously malicious paths without
|
||||
/// hitting the filesystem. It should be used in addition to `validate_path`,
|
||||
|
|
@ -117,11 +126,11 @@ pub fn validate_path_single_root(path: &Path, root: &Path) -> Result<PathBuf> {
|
|||
/// `true` if the path appears safe (no obvious traversal sequences),
|
||||
/// `false` if it contains suspicious patterns.
|
||||
pub fn path_looks_safe(path: &str) -> bool {
|
||||
// Reject paths with obvious traversal patterns
|
||||
!path.contains("..")
|
||||
&& !path.contains("//")
|
||||
&& !path.starts_with('/')
|
||||
&& path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit
|
||||
// Reject paths with obvious traversal patterns
|
||||
!path.contains("..")
|
||||
&& !path.contains("//")
|
||||
&& !path.starts_with('/')
|
||||
&& path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit
|
||||
}
|
||||
|
||||
/// Sanitizes a filename by removing or replacing dangerous characters.
|
||||
|
|
@ -140,26 +149,27 @@ pub fn path_looks_safe(path: &str) -> bool {
|
|||
///
|
||||
/// A sanitized filename safe for use on most filesystems.
|
||||
pub fn sanitize_filename(filename: &str) -> String {
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
// Allow alphanumeric, common punctuation, and unicode letters
|
||||
c.is_alphanumeric() || matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']')
|
||||
})
|
||||
.collect();
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
// Allow alphanumeric, common punctuation, and unicode letters
|
||||
c.is_alphanumeric()
|
||||
|| matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']')
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Remove leading dots to prevent hidden files
|
||||
let sanitized = sanitized.trim_start_matches('.');
|
||||
// Remove leading dots to prevent hidden files
|
||||
let sanitized = sanitized.trim_start_matches('.');
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
let sanitized = sanitized.trim();
|
||||
// Remove leading/trailing whitespace
|
||||
let sanitized = sanitized.trim();
|
||||
|
||||
// Ensure the filename isn't empty after sanitization
|
||||
if sanitized.is_empty() {
|
||||
"unnamed".to_string()
|
||||
} else {
|
||||
sanitized.to_string()
|
||||
}
|
||||
// Ensure the filename isn't empty after sanitization
|
||||
if sanitized.is_empty() {
|
||||
"unnamed".to_string()
|
||||
} else {
|
||||
sanitized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a base path with a relative path safely.
|
||||
|
|
@ -174,137 +184,140 @@ pub fn sanitize_filename(filename: &str) -> String {
|
|||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The joined path if safe, or an error if the relative path would escape the base.
|
||||
/// The joined path if safe, or an error if the relative path would escape the
|
||||
/// base.
|
||||
pub fn safe_join(base: &Path, relative: &str) -> Result<PathBuf> {
|
||||
// Reject absolute paths in the relative component
|
||||
if relative.starts_with('/') || relative.starts_with('\\') {
|
||||
// Reject absolute paths in the relative component
|
||||
if relative.starts_with('/') || relative.starts_with('\\') {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"relative path cannot be absolute".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject paths with .. traversal
|
||||
if relative.contains("..") {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"relative path cannot contain '..'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build the path and validate it stays within base
|
||||
let joined = base.join(relative);
|
||||
|
||||
// Canonicalize base for comparison
|
||||
let canonical_base = base.canonicalize().map_err(|e| {
|
||||
PinakesError::PathNotAllowed(format!(
|
||||
"failed to canonicalize base {}: {}",
|
||||
base.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// The joined path might not exist yet, so we can't canonicalize it directly.
|
||||
// Instead, we check each component
|
||||
let mut current = canonical_base.clone();
|
||||
for component in Path::new(relative).components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::Normal(name) => {
|
||||
current = current.join(name);
|
||||
},
|
||||
Component::ParentDir => {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"relative path cannot be absolute".to_string(),
|
||||
"path traversal detected".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject paths with .. traversal
|
||||
if relative.contains("..") {
|
||||
},
|
||||
Component::CurDir => continue,
|
||||
_ => {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"relative path cannot contain '..'".to_string(),
|
||||
"invalid path component".to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Build the path and validate it stays within base
|
||||
let joined = base.join(relative);
|
||||
|
||||
// Canonicalize base for comparison
|
||||
let canonical_base = base.canonicalize().map_err(|e| {
|
||||
PinakesError::PathNotAllowed(format!(
|
||||
"failed to canonicalize base {}: {}",
|
||||
base.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// The joined path might not exist yet, so we can't canonicalize it directly.
|
||||
// Instead, we check each component
|
||||
let mut current = canonical_base.clone();
|
||||
for component in Path::new(relative).components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::Normal(name) => {
|
||||
current = current.join(name);
|
||||
}
|
||||
Component::ParentDir => {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"path traversal detected".to_string(),
|
||||
));
|
||||
}
|
||||
Component::CurDir => continue,
|
||||
_ => {
|
||||
return Err(PinakesError::PathNotAllowed(
|
||||
"invalid path component".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(joined)
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use std::fs;
|
||||
|
||||
fn setup_test_dirs() -> TempDir {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join("allowed")).unwrap();
|
||||
fs::create_dir_all(temp.path().join("forbidden")).unwrap();
|
||||
fs::write(temp.path().join("allowed/file.txt"), "test").unwrap();
|
||||
fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap();
|
||||
temp
|
||||
}
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_allowed() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("allowed/file.txt");
|
||||
use super::*;
|
||||
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
fn setup_test_dirs() -> TempDir {
|
||||
let temp = TempDir::new().unwrap();
|
||||
fs::create_dir_all(temp.path().join("allowed")).unwrap();
|
||||
fs::create_dir_all(temp.path().join("forbidden")).unwrap();
|
||||
fs::write(temp.path().join("allowed/file.txt"), "test").unwrap();
|
||||
fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap();
|
||||
temp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_forbidden() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("forbidden/secret.txt");
|
||||
#[test]
|
||||
fn test_validate_path_allowed() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("allowed/file.txt");
|
||||
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_traversal() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("allowed/../forbidden/secret.txt");
|
||||
#[test]
|
||||
fn test_validate_path_forbidden() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("forbidden/secret.txt");
|
||||
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
|
||||
assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd");
|
||||
assert_eq!(sanitize_filename(".hidden"), "hidden");
|
||||
assert_eq!(sanitize_filename("file<with>bad:chars"), "filewithbadchars");
|
||||
assert_eq!(sanitize_filename(""), "unnamed");
|
||||
assert_eq!(sanitize_filename("..."), "unnamed");
|
||||
}
|
||||
#[test]
|
||||
fn test_validate_path_traversal() {
|
||||
let temp = setup_test_dirs();
|
||||
let allowed_roots = vec![temp.path().join("allowed")];
|
||||
let path = temp.path().join("allowed/../forbidden/secret.txt");
|
||||
|
||||
#[test]
|
||||
fn test_path_looks_safe() {
|
||||
assert!(path_looks_safe("normal/path/file.txt"));
|
||||
assert!(!path_looks_safe("../../../etc/passwd"));
|
||||
assert!(!path_looks_safe("path//double/slash"));
|
||||
}
|
||||
let result = validate_path(&path, &allowed_roots);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_join() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let base = temp.path();
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("normal.txt"), "normal.txt");
|
||||
assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd");
|
||||
assert_eq!(sanitize_filename(".hidden"), "hidden");
|
||||
assert_eq!(sanitize_filename("file<with>bad:chars"), "filewithbadchars");
|
||||
assert_eq!(sanitize_filename(""), "unnamed");
|
||||
assert_eq!(sanitize_filename("..."), "unnamed");
|
||||
}
|
||||
|
||||
// Valid join
|
||||
let result = safe_join(base, "subdir/file.txt");
|
||||
assert!(result.is_ok());
|
||||
#[test]
|
||||
fn test_path_looks_safe() {
|
||||
assert!(path_looks_safe("normal/path/file.txt"));
|
||||
assert!(!path_looks_safe("../../../etc/passwd"));
|
||||
assert!(!path_looks_safe("path//double/slash"));
|
||||
}
|
||||
|
||||
// Traversal attempt
|
||||
let result = safe_join(base, "../etc/passwd");
|
||||
assert!(result.is_err());
|
||||
#[test]
|
||||
fn test_safe_join() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let base = temp.path();
|
||||
|
||||
// Absolute path attempt
|
||||
let result = safe_join(base, "/etc/passwd");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
// Valid join
|
||||
let result = safe_join(base, "subdir/file.txt");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Traversal attempt
|
||||
let result = safe_join(base, "../etc/passwd");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Absolute path attempt
|
||||
let result = safe_join(base, "/etc/passwd");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue