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