fs.rs
⎇
Raw
1//! Safe filesystem access: every operation resolves
2//! `<server-root>/<user-root>/<requested-path>`, canonicalizes it and verifies
3//! the result is still inside the user's root (blocks `..` and symlink escapes).
4
5use std::path::{Component, Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8use chrono::DateTime;
9
10use crate::error::ApiError;
11
12#[derive(Debug, thiserror::Error)]
13pub enum FsError {
14 #[error("folder not found")]
15 NotFound,
16 #[error("not a folder")]
17 NotADirectory,
18 #[error("access denied")]
19 Forbidden,
20 #[error("the configured folder no longer exists")]
21 RootMissing,
22}
23
24impl From<FsError> for ApiError {
25 fn from(e: FsError) -> Self {
26 use axum::http::StatusCode as S;
27 let status = match &e {
28 FsError::NotFound => S::NOT_FOUND,
29 FsError::NotADirectory => S::BAD_REQUEST,
30 FsError::Forbidden => S::FORBIDDEN,
31 FsError::RootMissing => S::NOT_FOUND,
32 };
33 ApiError::new(status, e.to_string())
34 }
35}
36
37/// Resolve a user root (path relative to the server root) to a canonical
38/// absolute path, verified to be inside the server root.
39pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> {
40 let candidate = server_root.join(root_rel);
41 let canonical = candidate
42 .canonicalize()
43 .map_err(|_| FsError::RootMissing)?;
44 ensure_within(server_root, &canonical)?;
45 if !canonical.is_dir() {
46 return Err(FsError::RootMissing);
47 }
48 Ok(canonical)
49}
50
51/// Resolve a requested path (relative to a user root) safely.
52pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
53 let root_abs = resolve_root(server_root, root_rel)?;
54 let req = Path::new(req_rel);
55 for c in req.components() {
56 if matches!(c, Component::ParentDir) {
57 return Err(FsError::Forbidden);
58 }
59 }
60 let full = root_abs.join(req);
61 let full = full
62 .canonicalize()
63 .map_err(|e| match e.kind() {
64 std::io::ErrorKind::NotFound => FsError::NotFound,
65 _ => FsError::Forbidden,
66 })?;
67 ensure_within(&root_abs, &full)?;
68 Ok(full)
69}
70
71fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
72 if p == base || p.starts_with(base) {
73 Ok(())
74 } else {
75 Err(FsError::Forbidden)
76 }
77}
78
79#[derive(Debug, Clone, serde::Serialize)]
80pub struct Entry {
81 pub name: String,
82 pub is_dir: bool,
83 pub size: u64,
84 pub mtime: String,
85}
86
87/// List a directory (blocking — call via spawn_blocking).
88pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
89 let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
90 std::io::ErrorKind::NotFound => FsError::NotFound,
91 std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
92 _ => FsError::Forbidden,
93 })?;
94
95 let mut entries = Vec::new();
96 for e in rd.flatten() {
97 let name = e.file_name().to_string_lossy().into_owned();
98 // Follows symlinks; a broken link shows up as an empty file.
99 let meta = std::fs::metadata(e.path());
100 let (is_dir, size, mtime) = match meta {
101 Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)),
102 Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()),
103 };
104 entries.push(Entry {
105 name,
106 is_dir,
107 size,
108 mtime,
109 });
110 }
111
112 // Folders first, then case-insensitive name.
113 entries.sort_by(|a, b| {
114 b.is_dir
115 .cmp(&a.is_dir)
116 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
117 .then_with(|| a.name.cmp(&b.name))
118 });
119 Ok(entries)
120}
121
122fn mtime_str(m: &std::fs::Metadata) -> String {
123 let dt: Option<DateTime<chrono::Utc>> = m
124 .modified()
125 .ok()
126 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
127 .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
128 dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
129 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
130}
131