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 #[error("already exists")]
23 Conflict,
24 #[error("{0}")]
25 Invalid(String),
26}
27
28impl From<FsError> for ApiError {
29 fn from(e: FsError) -> Self {
30 use axum::http::StatusCode as S;
31 let status = match &e {
32 FsError::NotFound => S::NOT_FOUND,
33 FsError::NotADirectory => S::BAD_REQUEST,
34 FsError::Forbidden => S::FORBIDDEN,
35 FsError::RootMissing => S::NOT_FOUND,
36 FsError::Conflict => S::CONFLICT,
37 FsError::Invalid(_) => S::BAD_REQUEST,
38 };
39 ApiError::new(status, e.to_string())
40 }
41}
42
43/// Resolve a user root (path relative to the server root) to a canonical
44/// absolute path, verified to be inside the server root.
45pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> {
46 let candidate = server_root.join(root_rel);
47 let canonical = candidate
48 .canonicalize()
49 .map_err(|_| FsError::RootMissing)?;
50 ensure_within(server_root, &canonical)?;
51 if !canonical.is_dir() {
52 return Err(FsError::RootMissing);
53 }
54 Ok(canonical)
55}
56
57/// Resolve a requested path (relative to a user root) safely.
58pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
59 let root_abs = resolve_root(server_root, root_rel)?;
60 let req = Path::new(req_rel);
61 for c in req.components() {
62 if matches!(c, Component::ParentDir) {
63 return Err(FsError::Forbidden);
64 }
65 }
66 let full = root_abs.join(req);
67 let full = full
68 .canonicalize()
69 .map_err(|e| match e.kind() {
70 std::io::ErrorKind::NotFound => FsError::NotFound,
71 _ => FsError::Forbidden,
72 })?;
73 ensure_within(&root_abs, &full)?;
74 Ok(full)
75}
76
77fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
78 if p == base || p.starts_with(base) {
79 Ok(())
80 } else {
81 Err(FsError::Forbidden)
82 }
83}
84
85#[derive(Debug, Clone, serde::Serialize)]
86pub struct Entry {
87 pub name: String,
88 pub is_dir: bool,
89 pub size: u64,
90 pub mtime: String,
91}
92
93/// List a directory (blocking — call via spawn_blocking).
94pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
95 let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
96 std::io::ErrorKind::NotFound => FsError::NotFound,
97 std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
98 _ => FsError::Forbidden,
99 })?;
100
101 let mut entries = Vec::new();
102 for e in rd.flatten() {
103 let name = e.file_name().to_string_lossy().into_owned();
104 // Follows symlinks; a broken link shows up as an empty file.
105 let meta = std::fs::metadata(e.path());
106 let (is_dir, size, mtime) = match meta {
107 Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)),
108 Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()),
109 };
110 entries.push(Entry {
111 name,
112 is_dir,
113 size,
114 mtime,
115 });
116 }
117
118 // Folders first, then case-insensitive name.
119 entries.sort_by(|a, b| {
120 b.is_dir
121 .cmp(&a.is_dir)
122 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
123 .then_with(|| a.name.cmp(&b.name))
124 });
125 Ok(entries)
126}
127
128fn mtime_str(m: &std::fs::Metadata) -> String {
129 let dt: Option<DateTime<chrono::Utc>> = m
130 .modified()
131 .ok()
132 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
133 .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
134 dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
135 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
136}
137
138// ---------------------------------------------------------------------------
139// Mutations (milestone 3): mkdir, rename, remove, move, copy, upload
140// ---------------------------------------------------------------------------
141
142/// Resolve a directory that must exist (relative to a user root). Used as the
143/// base for operations that target the *parent* of the item.
144pub fn resolve_dir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
145 let full = resolve_path(server_root, root_rel, req_rel)?;
146 if !full.is_dir() {
147 return Err(FsError::NotADirectory);
148 }
149 Ok(full)
150}
151
152/// Validate a new single-component name (for rename / new folder).
153pub fn validate_name(name: &str) -> Result<(), FsError> {
154 let p = Path::new(name);
155 if name.is_empty()
156 || p.components().count() != 1
157 || name == "."
158 || name == ".."
159 || name.contains(['/', '\\', '\0'])
160 {
161 return Err(FsError::Invalid("invalid name".to_string()));
162 }
163 Ok(())
164}
165
166/// Create a directory (and any missing parents) inside a user root.
167pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
168 let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
169 if full.exists() {
170 return Err(FsError::Conflict);
171 }
172 std::fs::create_dir_all(&full).map_err(|e| io_err(e, &full))?;
173 Ok(())
174}
175
176/// Resolve a path that does not need to exist yet, but whose *parent* must.
177fn resolve_path_or_new(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
178 let root_abs = resolve_root(server_root, root_rel)?;
179 let req = Path::new(req_rel);
180 for c in req.components() {
181 if matches!(c, Component::ParentDir) {
182 return Err(FsError::Forbidden);
183 }
184 }
185 let full = root_abs.join(req);
186 // The parent must exist and stay inside the root.
187 let parent = full
188 .parent()
189 .filter(|p| !p.as_os_str().is_empty())
190 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
191 let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?;
192 ensure_within(&root_abs, &parent)?;
193 Ok(full)
194}
195
196/// Rename (or move within the same directory) an item.
197pub fn rename_item(
198 server_root: &Path,
199 root_rel: &str,
200 req_rel: &str,
201 new_name: &str,
202 overwrite: bool,
203) -> Result<(), FsError> {
204 validate_name(new_name)?;
205 let from = resolve_path(server_root, root_rel, req_rel)?;
206 let parent = from
207 .parent()
208 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
209 let to = parent.join(new_name);
210 if to.exists() {
211 if !overwrite || to.is_dir() || from.is_dir() {
212 return Err(FsError::Conflict);
213 }
214 std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?;
215 }
216 std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
217 Ok(())
218}
219
220/// Delete a file or a directory tree. Returns whether it was a directory.
221pub fn remove_item(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<bool, FsError> {
222 let full = resolve_path(server_root, root_rel, req_rel)?;
223 let is_dir = full.is_dir();
224 if is_dir {
225 std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
226 } else {
227 std::fs::remove_file(&full).map_err(|e| io_err(e, &full))?;
228 }
229 Ok(is_dir)
230}
231
232/// Overwrite an existing file's contents (the editor's save path).
233///
234/// The file must already exist and be a regular file. If `expected_mtime`
235/// (whole unix seconds) is provided and differs from the file's current mtime,
236/// the file changed on disk since it was read → `Conflict` (409). Returns the
237/// file's new mtime (unix seconds) after a successful write.
238pub fn save_file(
239 server_root: &Path,
240 root_rel: &str,
241 req_rel: &str,
242 content: &[u8],
243 expected_mtime: Option<i64>,
244) -> Result<i64, FsError> {
245 let full = resolve_path(server_root, root_rel, req_rel)?; // must exist
246 let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
247 if meta.is_dir() {
248 return Err(FsError::NotADirectory);
249 }
250 if let Some(expected) = expected_mtime {
251 let cur = meta
252 .modified()
253 .ok()
254 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
255 .map(|d| d.as_secs() as i64)
256 .unwrap_or(-1);
257 if cur != expected {
258 return Err(FsError::Conflict);
259 }
260 }
261 std::fs::write(&full, content).map_err(|e| io_err(e, &full))?;
262 // Read the new mtime so the client can anchor the next conflict check.
263 let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
264 let mtime = new_meta
265 .modified()
266 .ok()
267 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
268 .map(|d| d.as_secs() as i64)
269 .unwrap_or(0);
270 Ok(mtime)
271}
272
273fn io_err(e: std::io::Error, p: &Path) -> FsError {
274 tracing::warn!(error = %e, path = %p.display(), "filesystem error");
275 match e.kind() {
276 std::io::ErrorKind::NotFound => FsError::NotFound,
277 _ => FsError::Forbidden,
278 }
279}
280
281/// True if `a` is `b` or a descendant of `b` (both canonical).
282fn is_within_or_eq(base: &Path, p: &Path) -> bool {
283 p == base || p.starts_with(base)
284}
285
286/// Move an item (possibly across roots). `dst_dir_rel` is the destination
287/// directory (relative to `dst_root_rel`); the item keeps its base name.
288pub fn move_item(
289 server_root: &Path,
290 src_root_rel: &str,
291 src_rel: &str,
292 dst_root_rel: &str,
293 dst_dir_rel: &str,
294 overwrite: bool,
295) -> Result<(), FsError> {
296 let from = resolve_path(server_root, src_root_rel, src_rel)?;
297 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
298 let name = from
299 .file_name()
300 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
301 .to_owned();
302 let to = dst_dir.join(&name);
303
304 // Refuse moving a directory into itself or a descendant.
305 if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
306 return Err(FsError::Invalid(
307 "cannot move a folder into itself".to_string(),
308 ));
309 }
310 check_move_conflict(&to, &from, overwrite)?;
311
312 match std::fs::rename(&from, &to) {
313 Ok(()) => Ok(()),
314 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
315 copy_recursive(&from, &to)?;
316 if from.is_dir() {
317 std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?;
318 } else {
319 std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?;
320 }
321 Ok(())
322 }
323 Err(e) => Err(io_err(e, &to)),
324 }
325}
326
327/// Copy an item (possibly across roots).
328pub fn copy_item(
329 server_root: &Path,
330 src_root_rel: &str,
331 src_rel: &str,
332 dst_root_rel: &str,
333 dst_dir_rel: &str,
334 overwrite: bool,
335) -> Result<(), FsError> {
336 let from = resolve_path(server_root, src_root_rel, src_rel)?;
337 let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
338 let name = from
339 .file_name()
340 .ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
341 .to_owned();
342 let to = dst_dir.join(&name);
343
344 if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
345 return Err(FsError::Invalid(
346 "cannot copy a folder into itself".to_string(),
347 ));
348 }
349 check_move_conflict(&to, &from, overwrite)?;
350 copy_recursive(&from, &to)?;
351 Ok(())
352}
353
354/// Conflict rules shared by move and copy:
355/// - target is a directory → always conflict (no silent merge)
356/// - target is a file → conflict unless overwriting a file with a file
357fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> {
358 if to.exists() {
359 let to_dir = to.is_dir();
360 let from_dir = from.is_dir();
361 if to_dir || from_dir || !overwrite {
362 return Err(FsError::Conflict);
363 }
364 }
365 Ok(())
366}
367
368/// Recursively copy a file or directory tree, preserving mtime.
369pub fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
370 let meta = std::fs::metadata(src).map_err(|e| io_err(e, src))?;
371 if meta.is_dir() {
372 std::fs::create_dir(dst).map_err(|e| io_err(e, dst))?;
373 for e in std::fs::read_dir(src).map_err(|e| io_err(e, src))?.flatten() {
374 copy_recursive(&e.path(), &dst.join(e.file_name()))?;
375 }
376 } else {
377 std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
378 }
379 set_mtime(dst, meta.modified().ok());
380 Ok(())
381}
382
383fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) {
384 if let (Some(t), Ok(f)) = (t, std::fs::File::open(p)) {
385 let _ = f.set_modified(t);
386 }
387}
388