Tests: in-memory SQLite; security headers; edition 2024; just fmt

- Tests: new Db::open_in_memory(); the integration harness and the db
  unit tests (except the v1->v2 migration test, which needs a real file)
  now run on in-memory SQLite — no temp DB files, no WAL.
- Security headers on every response (api::router, so dev, prod and
  tests all get them): Content-Security-Policy tuned to the built Trunk
  frontend (same-origin only; 'unsafe-inline' scripts/styles required
  by Trunk's inline bootstrap + the context menu; 'wasm-unsafe-eval'
  for the in-origin WASM), X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: no-referrer.
  Asserted in the SPA integration test.
- Edition 2024 for both crates. Only breakage: std::env::{set,remove}_var
  are now unsafe (the SPA test wraps the three calls with a safety note).
- justfile: 'just fmt' recipe.

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commit84f1f3235dc37c0c5f73b333794bde1bbd0272a1
Parent708fb13
7 files changed, 73 insertions(+), 23 deletions(-)
▾Mjustfile
@@ -46,6 +46,10 @@ lint:
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
# Format the whole workspace.
fmt:
cargo fmt
# Coverage report (requires `cargo install cargo-llvm-cov` + `rustup component add llvm-tools`).
cov:
cargo llvm-cov -p server
▾Mserver/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
edition = "2024"
[[bin]]
name = "filebrowser-ng"
@@ -25,7 +25,7 @@ tar = "0.4"
flate2 = "1"
zstd = "0.13"
zip = "9.0.0-pre3"
tower-http = { version = "0.6", features = ["trace"] }
tower-http = { version = "0.6", features = ["trace", "set-header"] }
futures-util = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
▾Mserver/src/api/mod.rs
@@ -1,10 +1,21 @@
use std::sync::Arc;
use axum::http::HeaderValue;
use axum::routing::{delete, get, post, put};
use axum::Router;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::error::AppState;
/// Content-Security-Policy tuned to the built Trunk frontend.
///
/// * `script-src 'unsafe-inline'` — Trunk emits one inline bootstrap module
/// in index.html; every real JS file also carries an SRI integrity hash.
/// * `'wasm-unsafe-eval'` — compiling the same-origin WASM module.
/// * `style-src 'unsafe-inline'` — the context menu sets an inline `style=`.
/// * Everything else locked to the same origin; frames/plugins banned.
const CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
mod admin;
mod auth;
mod common;
@@ -36,4 +47,21 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/admin/settings", put(admin::update_settings))
.fallback(spa::fallback)
.with_state(state)
// Hard security headers on every response (API and static alike).
.layer(SetResponseHeaderLayer::overriding(
"content-security-policy".parse().unwrap(),
HeaderValue::from_static(CSP),
))
.layer(SetResponseHeaderLayer::overriding(
"x-content-type-options".parse().unwrap(),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
"x-frame-options".parse().unwrap(),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::overriding(
"referrer-policy".parse().unwrap(),
HeaderValue::from_static("no-referrer"),
))
}
▾Mserver/src/db.rs
@@ -72,6 +72,15 @@ impl Db {
Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
}
/// Open a fresh in-memory database (used by tests — no temp file needed).
pub async fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", "5000")?;
Self::migrate(&conn)?;
Ok(Self(Arc::new(tokio::sync::Mutex::new(conn))))
}
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
@@ -540,22 +549,22 @@ INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_writable_shares', '0'
mod tests {
use super::*;
async fn tmp() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("db.sqlite")).await.unwrap();
(dir, db)
// Most tests use an in-memory DB (the file-based path is still covered
// by `v1_db_migrates_to_v2` and the integration harness' `Db::open`).
async fn mem() -> Db {
Db::open_in_memory().await.unwrap()
}
async fn db_with_admin() -> (tempfile::TempDir, Db, User) {
let (dir, db) = tmp().await;
async fn db_with_admin() -> (Db, User) {
let db = mem().await;
let hash = crate::auth::hash_password("admin1234").unwrap();
let admin = db.create_admin("admin", &hash).await.unwrap();
(dir, db, admin)
(db, admin)
}
#[tokio::test]
async fn fresh_db_state() {
let (_d, db) = tmp().await;
let db = mem().await;
assert_eq!(db.user_count().await, 0);
assert_eq!(db.count_admins().await, 0);
assert!(!db.allow_writable_shares().await);
@@ -597,7 +606,7 @@ mod tests {
#[tokio::test]
async fn admin_user_and_passwords() {
let (_d, db, admin) = db_with_admin().await;
let (db, admin) = db_with_admin().await;
assert!(admin.is_admin);
assert!(admin.active);
// Root "." rw is assigned by create_admin.
@@ -619,7 +628,7 @@ mod tests {
#[tokio::test]
async fn sessions_lifecycle() {
let (_d, db, admin) = db_with_admin().await;
let (db, admin) = db_with_admin().await;
assert!(db.session_user("ghost-token").await.is_none());
db.create_session(admin.id, "tok1").await.unwrap();
let u = db.session_user("tok1").await.unwrap();
@@ -635,7 +644,7 @@ mod tests {
#[tokio::test]
async fn user_crud_and_roots() {
let (_d, db, _admin) = db_with_admin().await;
let (db, _admin) = db_with_admin().await;
let h = crate::auth::hash_password("bobpass1").unwrap();
let bob = db
.create_user("bob", &h, false, &[("docs".into(), "rw".into())])
@@ -714,7 +723,7 @@ mod tests {
#[tokio::test]
async fn shares_crud() {
let (_d, db, admin) = db_with_admin().await;
let (db, admin) = db_with_admin().await;
let s1 = db
.create_share(admin.id, "tok-a", "docs", false, "ro", None)
.await
@@ -755,7 +764,7 @@ mod tests {
#[tokio::test]
async fn settings_round_trip() {
let (_d, db, _admin) = db_with_admin().await;
let (db, _admin) = db_with_admin().await;
assert!(!db.allow_writable_shares().await);
db.set_allow_writable_shares(true).await.unwrap();
assert!(db.allow_writable_shares().await);
▾Mserver/tests/api_spa.rs
@@ -24,10 +24,13 @@ async fn spa_fallback_and_api_guards() {
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
// Point the dev asset dir at a controlled tempdir.
//
// `FBNG_DIST` is process-global; only this test (the sole test in this
// binary) touches it, and other test binaries are separate processes.
let dist = tempfile::tempdir().unwrap();
std::fs::write(dist.path().join("index.html"), "DIST-INDEX").unwrap();
std::fs::write(dist.path().join("app.css"), "body{}").unwrap();
std::env::set_var("FBNG_DIST", dist.path());
unsafe { std::env::set_var("FBNG_DIST", dist.path()) };
// Root serves index.html.
let r = c.get("/").await;
@@ -36,6 +39,14 @@ async fn spa_fallback_and_api_guards() {
assert_eq!(r.header("content-type").as_deref(), Some("text/html"));
assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
// Hard security headers on every response.
let csp = r.header("content-security-policy").unwrap();
assert!(csp.starts_with("default-src 'self'"), "CSP: {csp}");
assert!(csp.contains("frame-ancestors 'none'"), "CSP: {csp}");
assert_eq!(r.header("x-content-type-options").as_deref(), Some("nosniff"));
assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
assert_eq!(r.header("referrer-policy").as_deref(), Some("no-referrer"));
// A known asset is served with the right type.
let r = c.get("/app.css").await;
assert_eq!(r.status, StatusCode::OK);
@@ -53,10 +64,10 @@ async fn spa_fallback_and_api_guards() {
// Now with an *empty* dist dir → the friendly "build the frontend" hint.
let empty = tempfile::tempdir().unwrap();
std::env::set_var("FBNG_DIST", empty.path());
unsafe { std::env::set_var("FBNG_DIST", empty.path()) };
let r = c.get("/").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.text().contains("frontend has not been built"));
std::env::remove_var("FBNG_DIST");
unsafe { std::env::remove_var("FBNG_DIST") };
}
▾Mserver/tests/common/mod.rs
@@ -37,7 +37,6 @@ use tower::ServiceExt;
/// ```
pub struct Env {
pub root: tempfile::TempDir,
pub dbdir: tempfile::TempDir,
pub state: Arc<AppState>,
pub app: Router,
}
@@ -56,8 +55,8 @@ impl Env {
std::fs::write(p.join("editme.txt"), "v1").unwrap();
std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
let dbdir = tempfile::tempdir().unwrap();
let db = Db::open(&dbdir.path().join("db.sqlite")).await.unwrap();
// In-memory SQLite: no temp files, no WAL, faster than file-backed.
let db = Db::open_in_memory().await.unwrap();
let state = Arc::new(AppState {
db,
root: p.canonicalize().unwrap(),
@@ -66,7 +65,6 @@ impl Env {
let app = server::api::router(state.clone());
Self {
root,
dbdir,
state,
app,
}
▾Mweb/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "web"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
chrono = "0.4"