Server: close review findings (upload containment, ranges, login throttle)

- Upload: check the nearest existing ancestor and the final parent against
  the user's root before and after create_dir_all, so a symlinked directory
  can no longer carry an upload outside the root
- Download/preview: honour a single byte range (206/416, Accept-Ranges)
  so media seeking works
- Content-Disposition: ASCII fallback plus RFC 8187 filename*; control
  characters no longer break the header
- Login: growing per-name delay after repeated failures; unknown names
  verify against a dummy hash so timing does not reveal account names
- Shares: reject a non-RFC 3339 expires_at instead of storing a value
  that never expires
- Editor: mtime and bytes from one file handle; body limit raised so the
  localized 413 is reached
- Rename with overwrite is one atomic rename
- Admin user update runs in one transaction
- File share listing answers 400 "not a folder"
- Tests for all of the above

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit6f863b9945c4dea9fcaf3012212baf773f0b88bb
Parentd781b77
12 files changed, 532 insertions(+), 106 deletions(-)
▾Mserver/Cargo.toml
@@ -24,7 +24,7 @@ rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "sync"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util", "sync", "time"] }
tar = "0.4"
flate2 = "1"
zstd = "0.13"
▾Mserver/src/api/admin.rs
@@ -162,21 +162,27 @@ pub async fn update_user(
));
}
if let Some(pw) = &body.password {
validate_password(pw)?;
let hash = hash_password(pw).await?;
state.db.update_user_password(id, &hash).await?;
}
if let Some(is_admin) = body.is_admin {
state.db.set_user_admin(id, is_admin).await?;
}
if let Some(active) = body.active {
state.db.set_user_active(id, active).await?;
}
if let Some(roots) = &body.roots {
let pairs = validate_roots(&state, roots).await?;
state.db.set_user_roots(id, &pairs).await?;
}
let hash = match &body.password {
Some(pw) => {
validate_password(pw)?;
Some(hash_password(pw).await?)
}
None => None,
};
let pairs = match &body.roots {
Some(roots) => Some(validate_roots(&state, roots).await?),
None => None,
};
state
.db
.update_user(
id,
hash.as_deref(),
body.is_admin,
body.active,
pairs.as_deref(),
)
.await?;
let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
▾Mserver/src/api/auth.rs
@@ -179,7 +179,14 @@ pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<Credentials>,
) -> Result<Response, ApiError> {
let Some(user) = state.db.verify_password(&body.name, &body.password).await? else {
// Online guessing gets slower per failed attempt on this name.
let delay = auth::login_delay(&body.name);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let verified = state.db.verify_password(&body.name, &body.password).await?;
auth::record_login(&body.name, verified.is_some());
let Some(user) = verified else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"invalid name or password",
▾Mserver/src/api/files.rs
@@ -32,7 +32,7 @@ use crate::fs::{self, FsError};
use api_types::{FilesResp, Mutation, OkResp, Op, P_ACTION, P_OVERWRITE, SaveResp, UploadResp};
/// Upper bound for the in-memory text endpoint (preview, later editor).
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
pub(super) const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Query params
@@ -60,13 +60,25 @@ pub async fn file_get(
auth: AuthUser,
path: AxumPath<(i64, String)>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> {
let (root_id, req_rel) = path.0;
match query.action.as_deref() {
Some(a) if a == api_types::ACTION_DOWNLOAD => {
download(state, auth, root_id, req_rel, query.format.as_deref()).await
let range = range_header(&headers);
download(
state,
auth,
root_id,
req_rel,
query.format.as_deref(),
range,
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(state, auth, root_id, req_rel, range_header(&headers)).await
}
Some(a) if a == api_types::ACTION_PREVIEW => preview(state, auth, root_id, req_rel).await,
Some(a) if a == api_types::ACTION_CONTENT => content(state, auth, root_id, req_rel).await,
_ => {
let json = list_inner(state, auth, root_id, req_rel).await?;
@@ -83,14 +95,24 @@ pub async fn list_root(
auth: AuthUser,
path: AxumPath<i64>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> {
let root_id = path.0;
match query.action.as_deref() {
Some(a) if a == api_types::ACTION_DOWNLOAD => {
download(state, auth, root_id, String::new(), query.format.as_deref()).await
let range = range_header(&headers);
download(
state,
auth,
root_id,
String::new(),
query.format.as_deref(),
range,
)
.await
}
Some(a) if a == api_types::ACTION_PREVIEW => {
preview(state, auth, root_id, String::new()).await
preview(state, auth, root_id, String::new(), range_header(&headers)).await
}
Some(a) if a == api_types::ACTION_CONTENT => {
content(state, auth, root_id, String::new()).await
@@ -102,12 +124,23 @@ pub async fn list_root(
}
}
fn range_header(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
async fn list_inner(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<FilesResp>, ApiError> {
// A file share's root is the file itself: there is nothing to list.
if auth.share.as_ref().is_some_and(|s| s.is_file) {
return Err(FsError::NotADirectory.into());
}
let root = find_root(&auth.roots, root_id)?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
@@ -139,11 +172,29 @@ async fn list_inner(
// download / preview / content (milestone 4)
// ---------------------------------------------------------------------------
/// Escape a file name for a `Content-Disposition` header value.
fn disp_name(name: &str) -> String {
name.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace(['\n', '\r'], "_")
/// `Content-Disposition` parameters for `name`: an ASCII `filename=` fallback
/// (non-ASCII and control bytes become `_`) plus the RFC 8187 `filename*=`
/// that every current browser reads. Never fails header validation.
fn disposition(kind: &str, name: &str) -> String {
let ascii: String = name
.chars()
.map(|c| match c {
'"' | '\\' => '_',
c if c.is_ascii_graphic() || c == ' ' => c,
_ => '_',
})
.collect();
let mut enc = String::with_capacity(name.len() * 3);
for b in name.bytes() {
// attr-char per RFC 8187.
if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
enc.push(b as char);
} else {
use std::fmt::Write as _;
let _ = write!(enc, "%{b:02X}");
}
}
format!("{kind}; filename=\"{ascii}\"; filename*=UTF-8''{enc}")
}
/// Resolve the requested item to an absolute path + metadata (blocking).
@@ -187,13 +238,14 @@ async fn download(
root_id: i64,
req_rel: String,
format: Option<&str>,
range: Option<String>,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if !is_dir {
return file_response(&full, &name, size, false).await;
return file_response(&full, &name, size, false, range.as_deref()).await;
}
let fmt = format.and_then(ArchiveFormat::parse).ok_or_else(|| {
@@ -203,11 +255,7 @@ async fn download(
"err_bad_format",
)
})?;
let disp = format!(
"attachment; filename=\"{}.{}\"",
disp_name(&name),
fmt.extension()
);
let disp = disposition("attachment", &format!("{name}.{}", fmt.extension()));
let body = stream_archive(fmt, full, name);
Response::builder()
.status(StatusCode::OK)
@@ -228,6 +276,7 @@ async fn preview(
auth: AuthUser,
root_id: i64,
req_rel: String,
range: Option<String>,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) =
@@ -239,7 +288,7 @@ async fn preview(
"err_not_a_file",
));
}
file_response(&full, &name, size, true).await
file_response(&full, &name, size, true, range.as_deref()).await
}
/// `GET ...?action=content` — raw file bytes for the text preview/editor.
@@ -267,21 +316,17 @@ async fn content(
"err_too_large_preview",
));
}
let bytes = tokio::fs::read(&full).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let meta = tokio::fs::metadata(&full).await.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})?;
let mtime = fs::mtime_secs(&meta).unwrap_or(0);
// mtime and bytes from one handle, mtime first: a write in between would
// otherwise hand the editor a stale conflict anchor for fresh content.
let (mtime, bytes) = tokio::task::spawn_blocking(move || -> io::Result<(i64, Vec<u8>)> {
let mut f = std::fs::File::open(&full)?;
let mtime = fs::mtime_secs(&f.metadata()?).unwrap_or(0);
let mut bytes = Vec::with_capacity(size as usize);
f.read_to_end(&mut bytes)?;
Ok((mtime, bytes))
})
.await
.map_err(|_| io::Error::other("join"))??;
Ok((
[
(
@@ -387,20 +432,46 @@ async fn file_response(
name: &str,
size: u64,
inline: bool,
range: Option<&str>,
) -> Result<Response, ApiError> {
let mime = mime_guess::from_path(full)
.first_or_octet_stream()
.to_string();
let disp = if inline {
format!("inline; filename=\"{}\"", disp_name(name))
} else {
format!("attachment; filename=\"{}\"", disp_name(name))
let disp = disposition(if inline { "inline" } else { "attachment" }, name);
// A single `bytes=a-b` range (media seeking). Anything else is served whole.
let (start, end) = match parse_range(range, size) {
Some(Some(r)) => r,
Some(None) => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{size}"))
.body(axum::body::Body::empty())
.map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("bad response: {e}"),
)
});
}
None => (0, size),
};
let body = stream_file(full.to_path_buf());
let partial = (start, end) != (0, size);
let body = stream_file(full.to_path_buf(), start, end);
let mut res = Response::builder()
.status(StatusCode::OK)
.status(if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
})
.header(header::CONTENT_DISPOSITION, disp)
.header(header::CONTENT_LENGTH, size);
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, end - start);
if partial {
res = res.header(
header::CONTENT_RANGE,
format!("bytes {start}-{}/{size}", end - 1),
);
}
// A file the browser would parse as a document (HTML/SVG/XML) is served
// under the sandboxed policy, so it can render as a page without being
// able to act as the app. Derived from the same `mime` we declare.
@@ -423,8 +494,28 @@ async fn file_response(
})
}
/// Stream `path` to the client in chunks (blocking reader → channel).
fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
/// Parse a `Range` header against `size`. `None` = serve the whole file,
/// `Some(None)` = unsatisfiable, `Some(Some((start, end)))` = half-open range.
fn parse_range(range: Option<&str>, size: u64) -> Option<Option<(u64, u64)>> {
let spec = range?.strip_prefix("bytes=")?;
// ponytail: one range only; multipart/byteranges is not worth it here.
let (a, b) = spec.split_once('-')?;
let (start, end) = match (a.trim().parse::<u64>().ok(), b.trim().parse::<u64>().ok()) {
(Some(s), Some(e)) => (s, e.saturating_add(1).min(size)),
(Some(s), None) if b.trim().is_empty() => (s, size),
// Suffix form: the last N bytes.
(None, Some(n)) if a.trim().is_empty() => (size.saturating_sub(n), size),
_ => return None,
};
if start >= size || start >= end {
return Some(None);
}
Some(Some((start, end)))
}
/// Stream `path[start..end)` to the client in chunks (blocking reader → channel).
fn stream_file(path: std::path::PathBuf, start: u64, end: u64) -> axum::body::Body {
use std::io::Seek;
let (tx, rx) = mpsc::channel::<Vec<u8>>(16);
tokio::task::spawn_blocking(move || {
let mut f = match std::fs::File::open(&path) {
@@ -434,11 +525,17 @@ fn stream_file(path: std::path::PathBuf) -> axum::body::Body {
return;
}
};
if start > 0 && f.seek(io::SeekFrom::Start(start)).is_err() {
return;
}
let mut left = end - start;
let mut buf = vec![0u8; 256 * 1024];
loop {
match f.read(&mut buf) {
while left > 0 {
let want = buf.len().min(left as usize);
match f.read(&mut buf[..want]) {
Ok(0) => break,
Ok(n) => {
left -= n as u64;
// Client gone → stop producing.
if tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
@@ -799,17 +896,24 @@ async fn upload(
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, ApiError> {
let root = require_rw_root(&auth.roots, root_id)?;
let base = {
// `base` is the upload directory; `root_abs` the user's root, which is the
// containment boundary (a symlink may legitimately point elsewhere inside it).
let (root_abs, base) = {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
tokio::task::spawn_blocking(move || fs::resolve_dir(&server_root, &root_rel, &rel))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??
tokio::task::spawn_blocking(move || {
Ok::<_, FsError>((
fs::resolve_root(&server_root, &root_rel)?,
fs::resolve_dir(&server_root, &root_rel, &rel)?,
))
})
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??
};
let boundary = req
.headers()
@@ -863,18 +967,48 @@ async fn upload(
"err_bad_part_name",
)
})?;
if !parent.is_dir() {
let p = parent.to_path_buf();
tokio::task::spawn_blocking(move || std::fs::create_dir_all(&p))
.await
.map_err(|_| {
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error",
"err_internal",
)
})??;
}
// Create the parent, then canonicalize it and require it to still be
// inside the upload directory. `validate_rel_path` blocks `..`, but a
// symlinked directory on disk would otherwise carry the write outside.
let (p, b) = (parent.to_path_buf(), root_abs.clone());
let parent = tokio::task::spawn_blocking(move || {
let escape = || io::Error::other("upload parent escapes the root");
// Check the nearest existing ancestor *before* creating anything,
// so no directory is ever created outside the root either.
let mut existing = p.as_path();
while !existing.exists() {
existing = existing.parent().ok_or_else(escape)?;
}
if !fs::is_within_or_eq(&b, &existing.canonicalize()?) {
return Err(escape());
}
if !p.is_dir() {
std::fs::create_dir_all(&p)?;
}
let canon = p.canonicalize()?;
if !fs::is_within_or_eq(&b, &canon) {
return Err(escape());
}
Ok::<_, io::Error>(canon)
})
.await
.map_err(|_| io::Error::other("join"))?
.map_err(|e| {
tracing::warn!(error = %e, "upload parent rejected");
ApiError::localized(
StatusCode::FORBIDDEN,
"invalid file path in upload",
"err_bad_upload_path",
)
})?;
let Some(file_name) = target.file_name() else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"invalid part name",
"err_bad_part_name",
));
};
let target = parent.join(file_name);
if target.exists() && !overwrite {
// Drain this part and report it as a conflict at the end.
▾Mserver/src/api/mod.rs
@@ -104,6 +104,12 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
.fallback(spa::fallback)
// The editor save body is checked against `MAX_TEXT_BYTES` in the
// handler; axum's default limit is the same 2 MiB, which would win
// with a plain 413 instead of the localized error.
.layer(axum::extract::DefaultBodyLimit::max(
files::MAX_TEXT_BYTES as usize + 64 * 1024,
))
.with_state(state)
// Hard security headers on every response (API and static alike).
// CSP/XFO are `if_not_present` so file responses can substitute
▾Mserver/src/api/shares.rs
@@ -65,6 +65,18 @@ pub async fn create(
));
}
// Validated at the trust boundary: `is_expired` treats an unparseable
// value as "never expires", so garbage here would make a permanent share.
if let Some(e) = &body.expires_at
&& chrono::DateTime::parse_from_rfc3339(e).is_err()
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expires_at must be an RFC 3339 timestamp",
"err_bad_expires_at",
));
}
let root = auth
.roots
.iter()
▾Mserver/src/auth.rs
@@ -1,3 +1,6 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
@@ -43,6 +46,42 @@ fn hex_token(bytes: usize) -> String {
})
}
/// Failed logins per name within the last [`FAILURE_WINDOW`].
/// ponytail: process-wide map, keyed by name. Enough to blunt online guessing
/// on a single-node deployment; move to the DB if the server is ever scaled out.
static LOGIN_FAILURES: std::sync::Mutex<Option<HashMap<String, (u32, Instant)>>> =
std::sync::Mutex::new(None);
const FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);
/// How long a login attempt for `name` must wait before it is checked: 0 for
/// the first few tries, then growing per failure, capped at a few seconds. A
/// delay rather than a lockout, so an attacker cannot lock a real user out.
pub fn login_delay(name: &str) -> Duration {
let mut g = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
let map = g.get_or_insert_with(HashMap::new);
match map.get(&name.to_lowercase()) {
Some((n, at)) if at.elapsed() < FAILURE_WINDOW => delay_for(*n),
_ => Duration::ZERO,
}
}
pub fn record_login(name: &str, ok: bool) {
let mut g = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
let map = g.get_or_insert_with(HashMap::new);
map.retain(|_, (_, at)| at.elapsed() < FAILURE_WINDOW);
let key = name.to_lowercase();
if ok {
map.remove(&key);
} else {
let n = map.get(&key).map_or(0, |(n, _)| *n);
map.insert(key, (n + 1, Instant::now()));
}
}
fn delay_for(failures: u32) -> Duration {
Duration::from_millis(500 * u64::from(failures.saturating_sub(2)).min(10))
}
pub fn session_cookie(token: &str, https: bool) -> String {
let mut c =
format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}");
@@ -116,6 +155,29 @@ mod tests {
}
}
#[test]
fn login_delay_grows_after_free_tries() {
assert_eq!(delay_for(0), Duration::ZERO);
assert_eq!(delay_for(2), Duration::ZERO);
assert_eq!(delay_for(3), Duration::from_millis(500));
assert_eq!(delay_for(6), Duration::from_millis(2000));
assert_eq!(delay_for(100), Duration::from_millis(5000));
let name = "throttle-test-user";
assert_eq!(login_delay(name), Duration::ZERO);
for _ in 0..4 {
record_login(name, false);
}
assert_eq!(login_delay(name), Duration::from_millis(1000));
// Case-insensitive like the account names themselves.
assert_eq!(
login_delay("THROTTLE-test-USER"),
Duration::from_millis(1000)
);
record_login(name, true);
assert_eq!(login_delay(name), Duration::ZERO);
}
#[test]
fn session_cookie_shape() {
let c = session_cookie("tok123", false);
▾Mserver/src/db.rs
@@ -216,23 +216,28 @@ impl Db {
)
.optional()?
};
let Some((id, name, is_admin, hash, active, single_click, language)) = row else {
return Ok(None);
// An unknown or disabled name still pays for one Argon2 verify, so the
// response time does not reveal which names exist.
let (row, hash) = match row {
Some((id, name, is_admin, hash, active, single_click, language)) if active => {
(Some((id, name, is_admin, single_click, language)), hash)
}
_ => (None, DUMMY_HASH.clone()),
};
if !active {
return Ok(None);
}
// Argon2 is CPU-bound, so it must not run on an async worker thread.
let password = password.to_string();
let ok =
tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
.await
.unwrap_or(false);
let Some((id, name, is_admin, single_click, language)) = row else {
return Ok(None);
};
Ok(ok.then_some(User {
id,
name,
is_admin,
active,
active: true,
single_click,
language,
}))
@@ -405,6 +410,48 @@ impl Db {
Ok(())
}
/// Apply an admin edit atomically: every `Some` field is written in one
/// transaction, so a failure midway leaves the user unchanged.
pub async fn update_user(
&self,
id: i64,
pass_hash: Option<&str>,
is_admin: Option<bool>,
active: Option<bool>,
roots: Option<&[(String, Mode)]>,
) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
if let Some(h) = pass_hash {
tx.execute(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
params![h, id],
)?;
}
if let Some(a) = is_admin {
tx.execute(
"UPDATE users SET is_admin = ?1 WHERE id = ?2",
params![a as i64, id],
)?;
}
if let Some(a) = active {
tx.execute(
"UPDATE users SET active = ?1 WHERE id = ?2",
params![a as i64, id],
)?;
}
if let Some(roots) = roots {
tx.execute("DELETE FROM user_roots WHERE user_id = ?1", [id])?;
for (path, mode) in roots {
tx.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (?1, ?2, ?3)",
params![id, path, SqlMode(*mode)],
)?;
}
}
tx.commit()
}
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
@@ -548,6 +595,12 @@ fn map_share(r: &rusqlite::Row) -> DbResult<ShareRow> {
})
}
/// A hash of a random string nobody knows. Verified against when the login
/// name does not exist, so both paths cost one Argon2 run.
static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
crate::auth::hash_password(&crate::auth::random_token()).expect("argon2 hash")
});
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
▾Mserver/src/fs.rs
@@ -429,11 +429,9 @@ pub fn rename_item(
if to == from {
return Ok(());
}
if to.exists() {
if !overwrite || to.is_dir() || from.is_dir() {
return Err(FsError::Conflict);
}
std::fs::remove_file(&to).map_err(|e| io_err(e, &to))?;
// `rename` replaces a file target atomically; no remove-then-rename gap.
if to.exists() && (!overwrite || to.is_dir() || from.is_dir()) {
return Err(FsError::Conflict);
}
std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
Ok(())
▾Mserver/tests/api_files.rs
@@ -130,7 +130,7 @@ async fn download_single_file() {
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"editme.txt\"")
Some("attachment; filename=\"editme.txt\"; filename*=UTF-8''editme.txt")
);
assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
assert_eq!(r.body, b"v1");
@@ -141,6 +141,73 @@ async fn download_single_file() {
assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
}
#[tokio::test]
async fn download_encodes_non_ascii_and_control_characters_in_filename() {
let env = Env::new().await;
let admin = env.admin().await;
std::fs::write(env.file("Übersicht \"q\"\t.txt"), "x").unwrap();
let r = admin
.get(&format!(
"{}?action=download",
root_path("%C3%9Cbersicht%20%22q%22%09.txt")
))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
r.header("content-disposition").as_deref(),
Some(
"attachment; filename=\"_bersicht _q__.txt\"; \
filename*=UTF-8''%C3%9Cbersicht%20%22q%22%09.txt"
)
);
}
#[tokio::test]
async fn download_honours_single_byte_ranges() {
let env = Env::new().await;
let admin = env.admin().await;
let url = format!("{}?action=preview", root_path("blob.bin"));
let r = admin.get(&url).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("accept-ranges").as_deref(), Some("bytes"));
let get = |range: &'static str| {
let admin = &admin;
let url = url.clone();
async move {
admin
.raw(
axum::http::Method::GET,
&url,
&[("range", range)],
Vec::new(),
)
.await
}
};
let r = get("bytes=10-19").await;
assert_eq!(r.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(r.header("content-range").as_deref(), Some("bytes 10-19/64"));
assert_eq!(r.header("content-length").as_deref(), Some("10"));
assert_eq!(r.body, (10..20u8).collect::<Vec<_>>());
// Open end and suffix forms; an end past EOF is clamped.
let r = get("bytes=60-").await;
assert_eq!(r.body, (60..64u8).collect::<Vec<_>>());
let r = get("bytes=-4").await;
assert_eq!(r.body, (60..64u8).collect::<Vec<_>>());
let r = get("bytes=62-999").await;
assert_eq!(r.header("content-range").as_deref(), Some("bytes 62-63/64"));
// Out of range → 416 with the size; garbage → the whole file.
let r = get("bytes=64-70").await;
assert_eq!(r.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(r.header("content-range").as_deref(), Some("bytes */64"));
let r = get("items=1-2").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body.len(), 64);
}
#[tokio::test]
async fn download_folder_as_all_archive_formats() {
let env = Env::new().await;
@@ -152,7 +219,7 @@ async fn download_folder_as_all_archive_formats() {
assert_eq!(r.header("content-type").as_deref(), Some("application/zip"));
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.zip\"")
Some("attachment; filename=\"docs.zip\"; filename*=UTF-8''docs.zip")
);
let map = zip_map(&r.body);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
@@ -165,7 +232,7 @@ async fn download_folder_as_all_archive_formats() {
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar\"")
Some("attachment; filename=\"docs.tar\"; filename*=UTF-8''docs.tar")
);
let map = tar_map(&r.body, Compress::None);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
@@ -178,7 +245,7 @@ async fn download_folder_as_all_archive_formats() {
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.gz\"")
Some("attachment; filename=\"docs.tar.gz\"; filename*=UTF-8''docs.tar.gz")
);
let map = tar_map(&r.body, Compress::Gz);
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
@@ -190,7 +257,7 @@ async fn download_folder_as_all_archive_formats() {
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.zst\"")
Some("attachment; filename=\"docs.tar.zst\"; filename*=UTF-8''docs.tar.zst")
);
let map = tar_map(&r.body, Compress::Zst);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
@@ -284,6 +351,23 @@ async fn content_is_capped_at_two_mibibytes() {
assert_eq!(r.body.len(), big.len());
}
#[tokio::test]
async fn editor_save_over_two_mibibytes_is_rejected_with_the_localized_error() {
let env = Env::new().await;
let admin = env.admin().await;
let big = vec![b'x'; 2 * 1024 * 1024 + 1];
let r = admin
.put_content(
&format!("{}?action=content", root_path("editme.txt")),
&big,
None,
)
.await;
assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(r.json()["code"], "err_too_large_save");
assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v1");
}
#[tokio::test]
async fn editor_save_round_trip_and_conflict() {
let env = Env::new().await;
@@ -651,6 +735,37 @@ async fn upload_creates_files_and_folders() {
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
#[cfg(unix)]
#[tokio::test]
async fn upload_does_not_follow_symlinked_directories_out_of_the_root() {
let env = Env::new().await;
let admin = env.admin().await;
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), env.file("docs/link")).unwrap();
// Into the linked directory itself, and into a new folder below it.
for part in ["link/escaped.txt", "link/deeper/escaped.txt"] {
let r = admin
.post_multipart(&root_path("docs"), &[(part, b"leak")], "")
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{part}: {}", r.text());
}
assert!(!outside.path().join("escaped.txt").exists());
assert!(!outside.path().join("deeper").exists());
assert!(
std::fs::read_dir(outside.path()).unwrap().next().is_none(),
"no temp file may be left outside the root"
);
// A symlink that stays inside the root still works.
std::os::unix::fs::symlink(env.file("src"), env.file("docs/inside")).unwrap();
let r = admin
.post_multipart(&root_path("docs"), &[("inside/ok.txt", b"fine")], "")
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(std::fs::read(env.file("src/ok.txt")).unwrap(), b"fine");
}
#[tokio::test]
async fn read_only_root_blocks_writes_but_allows_reads() {
let env = Env::new().await;
▾Mserver/tests/api_shares.rs
@@ -97,14 +97,43 @@ async fn share_of_a_single_file() {
assert_eq!(r.body, b"# notes");
assert!(r.header("x-file-mtime").is_some());
// A path *below* the file share doesn't exist.
let r = anon
.get(&format!("/api/files/{root_id}/subdir?share={token}"))
.await;
assert!(matches!(
r.status,
StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST
));
// A file has no listing and nothing below it: 400 "not a folder".
for url in [
format!("/api/files/{root_id}?share={token}"),
format!("/api/files/{root_id}/subdir?share={token}"),
] {
let r = anon.get(&url).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{url}");
assert_eq!(r.json()["code"], "err_fs_not_a_dir");
}
}
#[tokio::test]
async fn share_expiry_must_be_rfc3339() {
let env = Env::new().await;
let admin = env.admin().await;
for bad in ["tomorrow", "2030-01-01", "2030-01-01 10:00", ""] {
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 1, "path": "docs", "writable": false, "expires_at": bad }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{bad:?}: {}", r.text());
assert_eq!(r.json()["code"], "err_bad_expires_at");
}
// Offsets other than Z are fine.
share(&admin, "docs", false, Some("2999-01-01T12:00:00+02:00")).await;
assert_eq!(
admin
.get("/api/shares")
.await
.json()
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
▾Mweb/src/i18n.rs
@@ -449,6 +449,7 @@ pub mod k {
pub const ERR_READ_ONLY_FOLDER: &str = "err_read_only_folder";
pub const ERR_RW_SHARES_DISABLED: &str = "err_rw_shares_disabled";
pub const ERR_RW_RO_FOLDER: &str = "err_rw_ro_folder";
pub const ERR_BAD_EXPIRES_AT: &str = "err_bad_expires_at";
pub const ERR_FS_NOT_FOUND: &str = "err_fs_not_found";
pub const ERR_FS_NOT_A_DIR: &str = "err_fs_not_a_dir";
pub const ERR_FS_FORBIDDEN: &str = "err_fs_forbidden";
@@ -756,6 +757,7 @@ const EN: &[(&str, &str)] = &[
(k::ERR_ALREADY_SET_UP, "server is already set up"),
(k::ERR_SHARE_NOT_FOUND, "share not found"),
(k::ERR_SHARE_EXPIRED, "this share has expired"),
(k::ERR_BAD_EXPIRES_AT, "the expiry date is invalid"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"a share token cannot be used here; sign in instead",
@@ -1174,6 +1176,7 @@ const DE: &[(&str, &str)] = &[
(k::ERR_ALREADY_SET_UP, "Server ist bereits eingerichtet"),
(k::ERR_SHARE_NOT_FOUND, "Freigabe nicht gefunden"),
(k::ERR_SHARE_EXPIRED, "diese Freigabe ist abgelaufen"),
(k::ERR_BAD_EXPIRES_AT, "das Ablaufdatum ist ungültig"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"ein Freigabetoken kann hier nicht verwendet werden; bitte anmelden",
@@ -1625,6 +1628,7 @@ const FR: &[(&str, &str)] = &[
(k::ERR_ALREADY_SET_UP, "le serveur est déjà configuré"),
(k::ERR_SHARE_NOT_FOUND, "partage introuvable"),
(k::ERR_SHARE_EXPIRED, "ce partage a expiré"),
(k::ERR_BAD_EXPIRES_AT, "la date d'expiration est invalide"),
(
k::ERR_SHARE_TOKEN_FORBIDDEN,
"un jeton de partage ne peut pas être utilisé ici ; connectez-vous à la place",