diff --git a/src/auth.rs b/src/auth.rs index f019fe5..4ea0470 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -97,10 +97,13 @@ impl Epoch { /// Sign every device out: the epoch moves on, and no cookie has it yet. pub async fn advance(state: &AppState) -> anyhow::Result { - let epoch: i64 = sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch") - .fetch_one(&state.db) - .await?; - state.epoch.store(epoch, std::sync::atomic::Ordering::SeqCst); + let epoch: i64 = + sqlx::query_scalar("UPDATE auth SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch") + .fetch_one(&state.db) + .await?; + state + .epoch + .store(epoch, std::sync::atomic::Ordering::SeqCst); Ok(epoch) } } @@ -114,9 +117,7 @@ pub fn credentials_match(config: &Config, username: &str, password: &str) -> boo pub fn session_cookie(config: &Config, token: &str) -> String { let max_age = config.session_days * 86_400; let secure = if config.secure_cookie { "; Secure" } else { "" }; - format!( - "{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}" - ) + format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}") } pub fn clear_cookie(config: &Config) -> String { @@ -165,9 +166,11 @@ impl OptionalFromRequestParts for Session { parts: &mut Parts, state: &AppState, ) -> Result, Self::Rejection> { - Ok(>::from_request_parts(parts, state) - .await - .ok()) + Ok( + >::from_request_parts(parts, state) + .await + .ok(), + ) } } @@ -315,13 +318,28 @@ mod tests { #[test] fn only_same_origin_writes_get_through() { - assert!(is_same_origin(&headers(&[("sec-fetch-site", "same-origin")]))); - assert!(!is_same_origin(&headers(&[("sec-fetch-site", "same-site")]))); - assert!(!is_same_origin(&headers(&[("sec-fetch-site", "cross-site")]))); + assert!(is_same_origin(&headers(&[( + "sec-fetch-site", + "same-origin" + )]))); + assert!(!is_same_origin(&headers(&[( + "sec-fetch-site", + "same-site" + )]))); + assert!(!is_same_origin(&headers(&[( + "sec-fetch-site", + "cross-site" + )]))); let host = ("host", "diary.example.org"); - assert!(is_same_origin(&headers(&[("origin", "https://diary.example.org"), host]))); - assert!(!is_same_origin(&headers(&[("origin", "https://evil.example.org"), host]))); + assert!(is_same_origin(&headers(&[ + ("origin", "https://diary.example.org"), + host + ]))); + assert!(!is_same_origin(&headers(&[ + ("origin", "https://evil.example.org"), + host + ]))); assert!(!is_same_origin(&headers(&[("origin", "null"), host]))); // curl, the CLI, a script with its own credentials. diff --git a/src/backup/mod.rs b/src/backup/mod.rs index 1c0809f..814d127 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -83,7 +83,12 @@ impl Backup { backup } - fn with_store(db: SqlitePool, config: Arc, store: SessionStore, disabled: Option) -> Arc { + fn with_store( + db: SqlitePool, + config: Arc, + store: SessionStore, + disabled: Option, + ) -> Arc { let configured = disabled.is_none() && matches!(store.load(), Ok(Some(_))); Arc::new(Self { db, @@ -103,7 +108,11 @@ impl Backup { } pub fn status(&self) -> Status { - let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let mut status = self + .status + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); status.pending = self.dirty.load(Ordering::Relaxed); status } @@ -189,7 +198,9 @@ impl Backup { Err(std::fs::TryLockError::WouldBlock) => { anyhow::bail!("another backup of this data directory is running — `backup-now` and the server take turns") } - Err(std::fs::TryLockError::Error(e)) => Err(e).context("could not lock the data directory for a backup"), + Err(std::fs::TryLockError::Error(e)) => { + Err(e).context("could not lock the data directory for a backup") + } } } @@ -280,14 +291,22 @@ impl Backup { .context("could not create the files folder on the device")?; self.state_set("device_files", &uid.to_string()).await?; // What the view remembers lived in a folder that is gone. - sqlx::query("DELETE FROM backup_view").execute(&self.db).await?; - sqlx::query("DELETE FROM backup_view_dirs").execute(&self.db).await?; + sqlx::query("DELETE FROM backup_view") + .execute(&self.db) + .await?; + sqlx::query("DELETE FROM backup_view_dirs") + .execute(&self.db) + .await?; uid } }; self.set(move |s| s.device = Some(name.clone())); - Ok(sync::Folders { data, uploads, files }) + Ok(sync::Folders { + data, + uploads, + files, + }) } /// A remembered node, if it is still remembered and still there. @@ -300,7 +319,12 @@ impl Backup { if stale { return Ok(None); } - let Some(uid) = self.state_get(key).await?.as_deref().and_then(sync::parse_uid) else { + let Some(uid) = self + .state_get(key) + .await? + .as_deref() + .and_then(sync::parse_uid) + else { return Ok(None); }; // A failed lookup is not a missing node — a network error must not be diff --git a/src/backup/proton.rs b/src/backup/proton.rs index a0ccca8..561d3d3 100644 --- a/src/backup/proton.rs +++ b/src/backup/proton.rs @@ -29,7 +29,11 @@ const APP_NAME: &str = "narl_workspace"; fn app_version() -> String { let version = env!("CARGO_PKG_VERSION"); - let channel = if version.starts_with("0.") { "alpha" } else { "stable" }; + let channel = if version.starts_with("0.") { + "alpha" + } else { + "stable" + }; format!("external-drive-{APP_NAME}@{version}-{channel}") } @@ -79,7 +83,11 @@ pub async fn login( })?; let tokens = session.current_tokens().await; - store.save(&StoredSession::from_session(&session, tokens, key_passphrases))?; + store.save(&StoredSession::from_session( + &session, + tokens, + key_passphrases, + ))?; Ok(()) } @@ -204,7 +212,8 @@ mod tests { .unwrap(); let store = SessionStore::new(&path); let stored = store.load().unwrap().unwrap(); - let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap(); + let session = + ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap(); let upgraded = upgrade(&store, &session, stored).await.unwrap(); assert_eq!(upgraded.key_passphrases.len(), 1); @@ -228,7 +237,8 @@ mod tests { .unwrap(); let store = SessionStore::new(&path); let stored = store.load().unwrap().unwrap(); - let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap(); + let session = + ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap(); let error = upgrade(&store, &session, stored).await.err().unwrap(); assert!(error.to_string().contains("proton-login"), "{error}"); @@ -239,13 +249,21 @@ mod tests { let version = app_version(); let (prefix, rest) = version.split_once('@').expect("name@version"); // Lowercase letters and underscores after the fixed `external-drive-`. - let name = prefix.strip_prefix("external-drive-").expect("the required prefix"); + let name = prefix + .strip_prefix("external-drive-") + .expect("the required prefix"); assert!(!name.is_empty()); - assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}"); + assert!( + name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), + "{name}" + ); let (semver, channel) = rest.rsplit_once('-').expect("version-channel"); assert!(matches!(channel, "stable" | "beta" | "alpha"), "{channel}"); assert_eq!(semver.split('.').count(), 3, "{semver}"); - assert!(semver.split('.').all(|part| part.parse::().is_ok()), "{semver}"); + assert!( + semver.split('.').all(|part| part.parse::().is_ok()), + "{semver}" + ); } } diff --git a/src/backup/session.rs b/src/backup/session.rs index bd1fb91..133b672 100644 --- a/src/backup/session.rs +++ b/src/backup/session.rs @@ -31,10 +31,10 @@ use aes_gcm::{ }; use anyhow::{anyhow, bail, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; -use rand::RngCore; use proton_drive_rs::KeySalt; use proton_sdk::account::KeyPassphrases; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; +use rand::RngCore; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; @@ -114,14 +114,20 @@ mod passphrases { use zeroize::Zeroizing; pub fn serialize(value: &KeyPassphrases, out: S) -> Result { - out.collect_map(value.iter().map(|(id, passphrase)| (id, STANDARD.encode(passphrase)))) + out.collect_map( + value + .iter() + .map(|(id, passphrase)| (id, STANDARD.encode(passphrase))), + ) } pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result { let encoded = BTreeMap::>::deserialize(input)?; let mut passphrases = KeyPassphrases::new(); for (id, passphrase) in encoded { - let bytes = STANDARD.decode(passphrase.as_bytes()).map_err(D::Error::custom)?; + let bytes = STANDARD + .decode(passphrase.as_bytes()) + .map_err(D::Error::custom)?; passphrases.insert(id, bytes); } Ok(passphrases) @@ -204,12 +210,18 @@ impl SessionStore { ), (false, _) => bytes, }; - let stored = serde_json::from_slice(&json) - .with_context(|| format!("{} is not a session this build understands", self.path.display()))?; + let stored = serde_json::from_slice(&json).with_context(|| { + format!( + "{} is not a session this build understands", + self.path.display() + ) + })?; if !sealed && self.key.is_some() { self.save(&stored)?; - tracing::info!("the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE"); + tracing::info!( + "the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE" + ); } Ok(Some(stored)) } @@ -261,7 +273,13 @@ fn seal(key: &[u8; 32], json: &[u8]) -> Vec { let mut nonce = [0u8; NONCE]; rand::thread_rng().fill_bytes(&mut nonce); let ciphertext = Aes256Gcm::new(key.into()) - .encrypt(Nonce::from_slice(&nonce), Payload { msg: json, aad: MAGIC }) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: json, + aad: MAGIC, + }, + ) .expect("AES-GCM seals any input this size"); let mut out = Vec::with_capacity(MAGIC.len() + NONCE + ciphertext.len()); @@ -278,7 +296,13 @@ fn open(key: &[u8; 32], sealed: &[u8]) -> Result>> { } let (nonce, ciphertext) = rest.split_at(NONCE); Aes256Gcm::new(key.into()) - .decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad: MAGIC }) + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: MAGIC, + }, + ) .map(Zeroizing::new) .map_err(|_| anyhow!("the key does not open it, or it was altered")) } @@ -363,8 +387,10 @@ mod tests { fn passphrases_survive_the_file_and_the_password_never_reaches_it() { let dir = tempfile::tempdir().unwrap(); let store = SessionStore::new(dir.path().join("proton-session.json")); - let passphrases = - KeyPassphrases::from_iter([("key-1", b"one\0\xff".to_vec()), ("key-2", b"two".to_vec())]); + let passphrases = KeyPassphrases::from_iter([ + ("key-1", b"one\0\xff".to_vec()), + ("key-2", b"two".to_vec()), + ]); // Even a session that still holds the old fields in memory writes // neither of them. @@ -414,10 +440,15 @@ mod tests { let path = dir.path().join("proton-session.json"); let passphrases = KeyPassphrases::from_iter([("key-1", b"secret".to_vec())]); - keyed(&path, [7; 32]).save(&stored(passphrases.clone())).unwrap(); + keyed(&path, [7; 32]) + .save(&stored(passphrases.clone())) + .unwrap(); let raw = std::fs::read(&path).unwrap(); assert!(raw.starts_with(MAGIC)); - assert!(!raw.windows(7).any(|w| w == b"refresh"), "tokens left in the clear"); + assert!( + !raw.windows(7).any(|w| w == b"refresh"), + "tokens left in the clear" + ); let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap(); assert!(loaded.key_passphrases == passphrases); @@ -425,7 +456,10 @@ mod tests { let wrong = keyed(&path, [8; 32]).load().err().unwrap(); assert!(format!("{wrong:#}").contains("does not open"), "{wrong:#}"); let none = SessionStore::new(&path).load().err().unwrap(); - assert!(none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"), "{none}"); + assert!( + none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"), + "{none}" + ); let mut flipped = raw.clone(); *flipped.last_mut().unwrap() ^= 1; @@ -437,7 +471,9 @@ mod tests { fn a_plain_file_is_sealed_the_first_time_a_key_reads_it() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("proton-session.json"); - SessionStore::new(&path).save(&stored(KeyPassphrases::new())).unwrap(); + SessionStore::new(&path) + .save(&stored(KeyPassphrases::new())) + .unwrap(); assert!(std::fs::read(&path).unwrap().starts_with(b"{")); let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap(); @@ -457,7 +493,10 @@ mod tests { for bad in ["", "not base64 at all", &STANDARD.encode([3u8; 16])] { std::fs::write(&file, bad).unwrap(); let error = read_key(&file).err().unwrap(); - assert!(error.to_string().contains("openssl rand -base64 32"), "{error}"); + assert!( + error.to_string().contains("openssl rand -base64 32"), + "{error}" + ); } assert!(read_key(&dir.path().join("missing")).is_err()); } diff --git a/src/backup/sync.rs b/src/backup/sync.rs index 4fe5836..6f0f759 100644 --- a/src/backup/sync.rs +++ b/src/backup/sync.rs @@ -89,7 +89,13 @@ pub async fn run( // Written once, and only ever re-read by a human who has lost everything // else — so it goes up before the data it explains. mirror - .bytes(data_folder, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes()) + .bytes( + data_folder, + "RESTORE.txt", + "RESTORE.txt", + "text/plain", + RESTORE.as_bytes(), + ) .await?; mirror.database(config, data_folder).await?; @@ -133,7 +139,13 @@ impl Mirror<'_> { .context("could not snapshot the database")?; let result = self - .file(data_folder, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot) + .file( + data_folder, + "diary.db", + "diary.db", + "application/vnd.sqlite3", + &snapshot, + ) .await; let _ = tokio::fs::remove_file(&snapshot).await; @@ -232,7 +244,10 @@ impl Mirror<'_> { return Ok(()); } - let uids: Vec = orphans.iter().filter_map(|(_, uid)| parse_uid(uid)).collect(); + let uids: Vec = orphans + .iter() + .filter_map(|(_, uid)| parse_uid(uid)) + .collect(); for (uid, result) in self.client.trash_nodes(&uids).await? { if let Err(e) = result { tracing::warn!(node = %uid, error = %e, "could not trash a pruned file"); diff --git a/src/backup/view.rs b/src/backup/view.rs index d9ec730..cab13be 100644 --- a/src/backup/view.rs +++ b/src/backup/view.rs @@ -65,7 +65,13 @@ impl Placed { pub(crate) fn clean(name: &str, slug: &str) -> String { let cleaned: String = name .chars() - .map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }) + .map(|c| { + if c == '/' || c == '\\' || c.is_control() { + '_' + } else { + c + } + }) .collect(); let cleaned = cleaned.trim(); if cleaned.is_empty() || cleaned == "." || cleaned == ".." { @@ -95,7 +101,10 @@ pub(crate) fn layout(nodes: &[TreeNode]) -> Vec { let mut segment: HashMap = HashMap::new(); let parent_of: HashMap> = nodes.iter().map(|n| (n.id, n.parent_id)).collect(); - for node in sorted.iter().filter(|n| n.kind == "space" || n.kind == "folder") { + for node in sorted + .iter() + .filter(|n| n.kind == "space" || n.kind == "folder") + { let name = clean(&node.name, &node.slug); let names = taken.entry(node.parent_id).or_default(); let name = if names.insert(name.to_lowercase()) { @@ -207,7 +216,11 @@ pub async fn run( Some(uid) => { tracked.insert( node_id, - Tracked { path: row.get("path"), media_id: row.get("media_id"), uid }, + Tracked { + path: row.get("path"), + media_id: row.get("media_id"), + uid, + }, ); } None => forget(db, node_id).await?, @@ -215,7 +228,10 @@ pub async fn run( } let mut dirs: HashMap = HashMap::new(); - for row in sqlx::query("SELECT path, node_uid FROM backup_view_dirs").fetch_all(db).await? { + for row in sqlx::query("SELECT path, node_uid FROM backup_view_dirs") + .fetch_all(db) + .await? + { if let Some(uid) = parse_uid(&row.get::("node_uid")) { dirs.insert(row.get("path"), uid); } @@ -229,7 +245,14 @@ pub async fn run( .into_iter() .collect(); - let mut view = View { db, config, client, root, dirs, summary }; + let mut view = View { + db, + config, + client, + root, + dirs, + summary, + }; // What is gone, or is no longer the same bytes, goes first: it frees the // names the moves below may want. @@ -426,12 +449,21 @@ impl View<'_> { Ok(Some(existing)) => { let uid = existing.uid.clone(); self.client - .upload_new_revision_from(&uid, std::fs::File::open(&path)?, size as i64, Vec::new(), Some(crate::now())) + .upload_new_revision_from( + &uid, + std::fs::File::open(&path)?, + size as i64, + Vec::new(), + Some(crate::now()), + ) .await .with_context(|| format!("could not upload files/{}", placed.path()))?; uid } - _ => return Err(e).with_context(|| format!("could not upload files/{}", placed.path())), + _ => { + return Err(e) + .with_context(|| format!("could not upload files/{}", placed.path())) + } }, }; @@ -465,7 +497,9 @@ impl View<'_> { for (uid, result) in results { match result { Ok(()) => self.summary.pruned += 1, - Err(e) => tracing::warn!(node = %uid, error = %e, "could not trash in files/"), + Err(e) => { + tracing::warn!(node = %uid, error = %e, "could not trash in files/") + } } } } @@ -503,7 +537,10 @@ mod tests { node(4, Some(2), "document", "notes"), node(5, Some(1), "file", "syllabus.pdf"), ]; - assert_eq!(paths(&layout(&tree)), ["uni/Algorithms/week 1.pdf", "uni/syllabus.pdf"]); + assert_eq!( + paths(&layout(&tree)), + ["uni/Algorithms/week 1.pdf", "uni/syllabus.pdf"] + ); } #[test] @@ -538,7 +575,10 @@ mod tests { #[test] fn a_file_whose_folder_is_missing_is_left_out() { - let tree = [node(1, None, "space", "uni"), node(3, Some(9), "file", "lost.pdf")]; + let tree = [ + node(1, None, "space", "uni"), + node(3, Some(9), "file", "lost.pdf"), + ]; assert!(layout(&tree).is_empty()); } diff --git a/src/cli.rs b/src/cli.rs index 185d715..78b5dfe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,7 +50,8 @@ pub async fn proton_login(config: &Config) -> Result<()> { if !std::io::stdin().is_terminal() { bail!("no terminal to ask for a password — set PROTON_PASSWORD, or run with `docker compose exec -it`"); } - rpassword::prompt_password("Proton password: ").context("could not read the password")? + rpassword::prompt_password("Proton password: ") + .context("could not read the password")? } }; @@ -62,7 +63,10 @@ pub async fn proton_login(config: &Config) -> Result<()> { }) .await?; - println!("Logged in. The session is stored at {}.", store.path().display()); + println!( + "Logged in. The session is stored at {}.", + store.path().display() + ); println!("Backups start with the next server start, or run `narl-workspace backup-now`."); Ok(()) } @@ -96,7 +100,10 @@ pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> { "Quiet: {}s after the last change", config.backup.debounce.as_secs() ); - println!("Prune: {}", if config.backup.prune { "on" } else { "off" }); + println!( + "Prune: {}", + if config.backup.prune { "on" } else { "off" } + ); let mirrored: i64 = sqlx::query_scalar("SELECT count(*) FROM backup_files") .fetch_one(db) @@ -142,7 +149,9 @@ pub async fn mail_test(db: SqlitePool, config: Arc) -> Result<()> { .send_test() .await .context("the test mail could not be sent")?; - println!("Sent. If it does not arrive, the relay accepted it and something after that did not."); + println!( + "Sent. If it does not arrive, the relay accepted it and something after that did not." + ); Ok(()) } @@ -162,13 +171,19 @@ pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> { println!("Timezone: {}", mail.timezone); match mail.reminder_at { Some(at) if mail.sends("reminder") => { - println!("Reminder: {:02}:{:02} when nothing was written", at.hour, at.minute) + println!( + "Reminder: {:02}:{:02} when nothing was written", + at.hour, at.minute + ) } _ => println!("Reminder: off"), } match mail.card_due_at { Some(at) if mail.sends("card_due") => { - println!("Cards: {:02}:{:02} for due and overdue cards", at.hour, at.minute) + println!( + "Cards: {:02}:{:02} for due and overdue cards", + at.hour, at.minute + ) } _ => println!("Cards: off"), } @@ -181,7 +196,10 @@ pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> { ), _ => println!("Digest: off"), } - println!("Logins: {}", if mail.sends("login") { "on" } else { "off" }); + println!( + "Logins: {}", + if mail.sends("login") { "on" } else { "off" } + ); let row = sqlx::query( "SELECT count(*) AS queued, diff --git a/src/config.rs b/src/config.rs index 49d105d..b47d4b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,7 +74,9 @@ fn flag(key: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), - _ => anyhow::bail!("WORKSPACE_{key} must be 1 or 0 (true/false, yes/no, on/off), not {raw:?}"), + _ => anyhow::bail!( + "WORKSPACE_{key} must be 1 or 0 (true/false, yes/no, on/off), not {raw:?}" + ), } } @@ -162,7 +164,10 @@ fn parse_time(key: &str, raw: &str) -> Result> { .split_once(':') .with_context(|| format!("{key} must look like 20:00, or be `off`"))?; let time = TimeOfDay { - hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?, + hour: hour + .trim() + .parse() + .with_context(|| format!("{key}: {hour:?} is not an hour"))?, minute: minute .trim() .parse() @@ -265,8 +270,9 @@ impl Config { // arithmetic on it that must not overflow. let session_days = number("SESSION_DAYS", 30, 1..=3650)? as i64; // Up to a terabyte, in bytes. - let max_upload_bytes = usize::try_from(number("MAX_UPLOAD_MB", 1024, 1..=1024 * 1024)? * 1024 * 1024) - .context("WORKSPACE_MAX_UPLOAD_MB is more than this machine can address")?; + let max_upload_bytes = + usize::try_from(number("MAX_UPLOAD_MB", 1024, 1..=1024 * 1024)? * 1024 * 1024) + .context("WORKSPACE_MAX_UPLOAD_MB is more than this machine can address")?; // A vault that locks the moment it opens could not be used; one that // stays open for more than a day is not much of a vault. let vault_idle = Duration::from_secs(number("VAULT_IDLE_MIN", 15, 1..=24 * 60)? * 60); @@ -397,7 +403,10 @@ mod tests { #[test] fn numbers_out_of_bounds_stop_the_start() { assert_eq!(number("TEST_UNSET_NUMBER", 7, 1..=10).unwrap(), 7); - assert_eq!(number(&with("TEST_NUMBER_OK", " 9 "), 7, 1..=10).unwrap(), 9); + assert_eq!( + number(&with("TEST_NUMBER_OK", " 9 "), 7, 1..=10).unwrap(), + 9 + ); assert!(number(&with("TEST_NUMBER_ZERO", "0"), 7, 1..=10).is_err()); assert!(number(&with("TEST_NUMBER_HUGE", "99999999999999999999"), 7, 1..=10).is_err()); assert!(number(&with("TEST_NUMBER_NEGATIVE", "-1"), 7, 1..=10).is_err()); @@ -416,11 +425,17 @@ mod tests { fn a_time_of_day_is_read_as_written() { assert_eq!( parse_time("T", "20:00").unwrap(), - Some(TimeOfDay { hour: 20, minute: 0 }) + Some(TimeOfDay { + hour: 20, + minute: 0 + }) ); assert_eq!( parse_time("T", "07:45").unwrap(), - Some(TimeOfDay { hour: 7, minute: 45 }) + Some(TimeOfDay { + hour: 7, + minute: 45 + }) ); assert_eq!(parse_time("T", "off").unwrap(), None); } @@ -436,11 +451,23 @@ mod tests { fn a_digest_is_a_weekday_and_a_time() { assert_eq!( parse_digest("sun 18:00").unwrap(), - Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 })) + Some(( + Weekday::Sun, + TimeOfDay { + hour: 18, + minute: 0 + } + )) ); assert_eq!( parse_digest("Monday 09:30").unwrap(), - Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 })) + Some(( + Weekday::Mon, + TimeOfDay { + hour: 9, + minute: 30 + } + )) ); assert_eq!(parse_digest("off").unwrap(), None); assert!(parse_digest("someday 18:00").is_err()); diff --git a/src/mail/mod.rs b/src/mail/mod.rs index 6dffa4e..19b517f 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -50,9 +50,7 @@ impl Mailer { // A URL with credentials has already set them; this only covers the // relays that want them and a URL that keeps them out of the string. let transport = match (config::var("SMTP_USER"), config::var("SMTP_PASSWORD")) { - (Some(user), Some(password)) => { - transport.credentials(Credentials::new(user, password)) - } + (Some(user), Some(password)) => transport.credentials(Credentials::new(user, password)), _ => transport, } .build(); @@ -271,14 +269,18 @@ mod tests { async fn mails_given_up_on_do_not_hold_up_the_rest() { let db = outbox().await; for n in 0..60 { - super::enqueue(&db, "reminder", &format!("dead:{n}"), "s", "b").await.unwrap(); + super::enqueue(&db, "reminder", &format!("dead:{n}"), "s", "b") + .await + .unwrap(); } sqlx::query("UPDATE notifications SET attempts = ?1, last_attempt_at = 0") .bind(MAX_ATTEMPTS) .execute(&db) .await .unwrap(); - super::enqueue(&db, "reminder", "reminder:2026-10-20", "s", "b").await.unwrap(); + super::enqueue(&db, "reminder", "reminder:2026-10-20", "s", "b") + .await + .unwrap(); let next: Vec = sqlx::query_scalar( "SELECT dedupe_key FROM notifications WHERE sent_at IS NULL AND attempts < ?1 diff --git a/src/main.rs b/src/main.rs index 913cda6..abac4f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,11 +18,9 @@ mod vault; use std::sync::Arc; use anyhow::{Context, Result}; -use axum::Router; -use sqlx::sqlite::{ - SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous, -}; use axum::http::{header, HeaderValue}; +use axum::Router; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; use tower_http::{ compression::CompressionLayer, set_header::SetResponseHeaderLayer, @@ -200,17 +198,16 @@ async fn snapshot_before_migrating( migrator: &sqlx::migrate::Migrator, config: &Config, ) -> Result<()> { - let applied: Option = match sqlx::query_scalar( - "SELECT MAX(version) FROM _sqlx_migrations WHERE success", - ) - .fetch_one(db) - .await - { - Ok(version) => version, - // No bookkeeping table: nothing has ever been migrated, so there is - // nothing to keep. - Err(_) => return Ok(()), - }; + let applied: Option = + match sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success") + .fetch_one(db) + .await + { + Ok(version) => version, + // No bookkeeping table: nothing has ever been migrated, so there is + // nothing to keep. + Err(_) => return Ok(()), + }; let Some(applied) = applied else { return Ok(()); }; @@ -305,7 +302,10 @@ fn is_slow(method: &axum::http::Method, path: &str) -> bool { } } -async fn deadline(request: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response { +async fn deadline( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { use axum::response::IntoResponse; if is_slow(request.method(), request.uri().path()) { return next.run(request).await; @@ -351,7 +351,9 @@ fn compressible( headers: &axum::http::HeaderMap, _: &axum::http::Extensions, ) -> bool { - if status == axum::http::StatusCode::PARTIAL_CONTENT || headers.contains_key(header::ACCEPT_RANGES) { + if status == axum::http::StatusCode::PARTIAL_CONTENT + || headers.contains_key(header::ACCEPT_RANGES) + { return false; } let kind = headers diff --git a/src/notify/mod.rs b/src/notify/mod.rs index bbf1def..34c7fa4 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -289,12 +289,11 @@ impl Notifier { .rows_affected(); // The upsert touches a row either way, so "new" is asked separately. - let first_seen: i64 = sqlx::query_scalar( - "SELECT first_seen_at FROM login_events WHERE device_hash = ?", - ) - .bind(&hash) - .fetch_one(&self.db) - .await?; + let first_seen: i64 = + sqlx::query_scalar("SELECT first_seen_at FROM login_events WHERE device_hash = ?") + .bind(&hash) + .fetch_one(&self.db) + .await?; if inserted == 0 || first_seen != now { return Ok(()); } @@ -411,7 +410,11 @@ fn still_due(tz: Tz, key: &str, card: &CardNow) -> bool { let queued_for = key.split(':').nth(2); card.done_at.is_none() && card.has_board == Some(true) - && card.due_at.map(|due| local_day(tz, due).to_string()).as_deref() == queued_for + && card + .due_at + .map(|due| local_day(tz, due).to_string()) + .as_deref() + == queued_for } /// Run the notifier for as long as the server lives. @@ -550,18 +553,39 @@ mod tests { #[test] fn a_card_mail_is_only_sent_while_it_is_true() { let tz = chrono_tz::UTC; - let due = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 20, 12, 0, 0).unwrap().timestamp(); + let due = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 20, 12, 0, 0) + .unwrap() + .timestamp(); let key = "card_due:7:2026-10-20:due"; - let open = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(true) }; + let open = super::CardNow { + due_at: Some(due), + done_at: None, + has_board: Some(true), + }; assert!(super::still_due(tz, key, &open)); - let finished = super::CardNow { done_at: Some(due), ..open }; + let finished = super::CardNow { + done_at: Some(due), + ..open + }; assert!(!super::still_due(tz, key, &finished)); - let moved = super::CardNow { due_at: Some(due + 86_400), done_at: None, has_board: Some(true) }; + let moved = super::CardNow { + due_at: Some(due + 86_400), + done_at: None, + has_board: Some(true), + }; assert!(!super::still_due(tz, key, &moved)); - let board_off = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(false) }; + let board_off = super::CardNow { + due_at: Some(due), + done_at: None, + has_board: Some(false), + }; assert!(!super::still_due(tz, key, &board_off)); - let deleted = super::CardNow { due_at: None, done_at: None, has_board: None }; + let deleted = super::CardNow { + due_at: None, + done_at: None, + has_board: None, + }; assert!(!super::still_due(tz, key, &deleted)); } @@ -591,7 +615,12 @@ mod tests { let (start, end) = day_bounds(Berlin, today); assert_eq!(end - start, 86_400); // 19:59 local is 17:59 UTC in summer time. - assert!(!time_reached(Berlin, start + 19 * 3600 + 59 * 60, today, AT)); + assert!(!time_reached( + Berlin, + start + 19 * 3600 + 59 * 60, + today, + AT + )); assert!(time_reached(Berlin, start + 20 * 3600, today, AT)); } diff --git a/src/routes/boards.rs b/src/routes/boards.rs index a7cf785..8901f46 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -159,10 +159,9 @@ pub struct AgendaCard { pub async fn agenda(_: Session, State(state): State) -> AppResult> { let cards = agenda_cards(&state.db, crate::now() + (AGENDA_DAYS + 1) * 86400).await?; - let has_diary: Option = - sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'") - .fetch_optional(&state.db) - .await?; + let has_diary: Option = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'") + .fetch_optional(&state.db) + .await?; let diary_today = match has_diary { Some(_) => { let tz = state.config.mail.timezone; @@ -209,7 +208,11 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult("is_first") { "inbox" } else { "doing" }, + role: if row.get::("is_first") { + "inbox" + } else { + "doing" + }, }) .collect()) } @@ -411,11 +414,12 @@ pub(crate) async fn restore( let mut count = 0; for (list, id) in lists.iter().zip(ids) { - let mut position: i64 = - sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1") - .bind(id) - .fetch_one(&mut *db) - .await?; + let mut position: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(position), -1) + 1 FROM cards WHERE list_id = ?1", + ) + .bind(id) + .fetch_one(&mut *db) + .await?; for card in &list.cards { sqlx::query( "INSERT INTO cards (list_id, node_id, title, body, due_at, done_at, position, @@ -638,13 +642,12 @@ pub async fn create_card( linkable(&state.db, input.node_id).await?; let mut tx = crate::db::begin_write(&state.db).await?; - let position: i64 = sqlx::query( - "SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1", - ) - .bind(list_id) - .fetch_one(&mut *tx) - .await? - .get("next"); + let position: i64 = + sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1") + .bind(list_id) + .fetch_one(&mut *tx) + .await? + .get("next"); let now = crate::now(); let id: i64 = sqlx::query( @@ -773,7 +776,9 @@ pub async fn move_card( let mut tx = crate::db::begin_write(&state.db).await?; let now = crate::now(); - let last = last_list(&mut *tx, to).await?.map_or(input.list_id, |(id, _)| id); + let last = last_list(&mut *tx, to) + .await? + .map_or(input.list_id, |(id, _)| id); relocate(&mut tx, ¤t, input.list_id, last, input.position, now).await?; tx.commit().await?; @@ -802,10 +807,11 @@ async fn relocate( .execute(&mut *db) .await?; - let ids: Vec = sqlx::query_scalar("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id") - .bind(to) - .fetch_all(&mut *db) - .await?; + let ids: Vec = + sqlx::query_scalar("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id") + .bind(to) + .fetch_all(&mut *db) + .await?; let target = position.unwrap_or(i64::MAX).max(0) as usize; for (position, id) in reordered(&ids, card_id, target).iter().enumerate() { @@ -863,7 +869,10 @@ mod tests { .connect("sqlite::memory:") .await .expect("an in-memory database"); - sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrations apply"); let space: i64 = sqlx::query_scalar( "INSERT INTO nodes (parent_id, kind, name, slug, body, has_board, created_at, updated_at) @@ -916,7 +925,10 @@ mod tests { .collect(); assert_eq!( titles, - vec![("due soon".to_string(), "inbox"), ("in hand".to_string(), "doing")] + vec![ + ("due soon".to_string(), "inbox"), + ("in hand".to_string(), "doing") + ] ); } @@ -964,14 +976,18 @@ mod tests { let current = card(&db, id).await.unwrap(); let mut conn = db.acquire().await.unwrap(); - relocate(&mut conn, ¤t, done, done, None, 100).await.unwrap(); + relocate(&mut conn, ¤t, done, done, None, 100) + .await + .unwrap(); drop(conn); assert_eq!(done_at(db.clone()).await, Some(100)); // A list added after the last one becomes "done", so the card that // was finished in the old last list is open again. let later = list(2).await; - settle(&mut db.acquire().await.unwrap(), space).await.unwrap(); + settle(&mut db.acquire().await.unwrap(), space) + .await + .unwrap(); assert_eq!(done_at(db.clone()).await, None); // Deleting the new last list hands "done" back to the old one. @@ -980,7 +996,9 @@ mod tests { .execute(&db) .await .unwrap(); - settle(&mut db.acquire().await.unwrap(), space).await.unwrap(); + settle(&mut db.acquire().await.unwrap(), space) + .await + .unwrap(); assert!(done_at(db.clone()).await.is_some()); } diff --git a/src/routes/export.rs b/src/routes/export.rs index d25e116..d4ac4fb 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -67,7 +67,9 @@ fn tree_path(nodes: &HashMap, parent: Option, leaf: String // cannot spin here. for _ in 0..nodes.len() { let Some(id) = parent else { break }; - let Some(ancestor) = nodes.get(&id) else { break }; + let Some(ancestor) = nodes.get(&id) else { + break; + }; segments.push(ancestor.slug.clone()); parent = ancestor.parent_id; } @@ -115,7 +117,13 @@ fn day_string(seconds: i64) -> String { fn safe_name(original: &str) -> String { let safe: String = original .chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) .collect(); safe.trim_matches(['.', '_']).to_string() } @@ -139,7 +147,11 @@ fn file_path( taken: &mut std::collections::HashSet, ) -> String { let safe = safe_name(&node.name); - let name = if safe.is_empty() { node.slug.clone() } else { safe }; + let name = if safe.is_empty() { + node.slug.clone() + } else { + safe + }; let path = tree_path(nodes, node.parent_id, name.clone()); if taken.insert(path.clone()) { return path; @@ -266,7 +278,8 @@ pub async fn export(_: Session, State(state): State) -> AppResult = nodes.values().collect(); ordered.sort_by_key(|n| n.id); - let mut taken: HashSet = HashSet::from(["README.txt".into(), manifest::FILE_NAME.into()]); + let mut taken: HashSet = + HashSet::from(["README.txt".into(), manifest::FILE_NAME.into()]); let containers: Vec<(&ExportNode, String)> = ordered .iter() .filter(|n| is_container(&n.kind)) @@ -295,10 +308,17 @@ pub async fn export(_: Session, State(state): State) -> AppResult = documents .iter() .map(|(_, path)| (format!("/n/{}", path.trim_end_matches(".md")), path.clone())) - .chain(containers.iter().map(|(_, path)| (format!("/n/{path}"), path.clone()))) + .chain( + containers + .iter() + .map(|(_, path)| (format!("/n/{path}"), path.clone())), + ) .collect(); - let mut meta = Manifest { format: manifest::FORMAT, ..Manifest::default() }; + let mut meta = Manifest { + format: manifest::FORMAT, + ..Manifest::default() + }; let mut note = |path: &str, node: &ExportNode| { meta.nodes.insert( path.to_string(), @@ -335,9 +355,11 @@ pub async fn export(_: Session, State(state): State) -> AppResult format!("]({}{}", relative_link(path, target), &caps[2]), - None => caps[0].to_string(), + let body = NODE_LINK.replace_all(&body, |caps: ®ex::Captures| { + match addresses.get(&caps[1]) { + Some(target) => format!("]({}{}", relative_link(path, target), &caps[2]), + None => caps[0].to_string(), + } }); let document = if node.name.trim().is_empty() { body.into_owned() @@ -364,9 +386,13 @@ pub async fn export(_: Session, State(state): State) -> AppResult = match (&key, sealed.contains(id)) { // A sealed file that will not open is not a file to leave out // quietly: the archive would claim to be complete and not be. - (Some(key), true) => Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| { - AppError::BadRequest(format!("{entry} is sealed and would not open ({e}) — nothing was exported")) - })?), + (Some(key), true) => { + Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| { + AppError::BadRequest(format!( + "{entry} is sealed and would not open ({e}) — nothing was exported" + )) + })?) + } // A row whose file has gone missing must not fail the whole // export; there is nothing to put in its place either way. _ => match std::fs::File::open(&path) { @@ -409,7 +435,11 @@ pub async fn export(_: Session, State(state): State) -> AppResult (String, String) { .map(str::to_string); match heading { - Some(title) => (title, lines.collect::>().join("\n").trim_start().to_string()), + Some(title) => ( + title, + lines + .collect::>() + .join("\n") + .trim_start() + .to_string(), + ), None => { let name = file_name(path); let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem); @@ -287,9 +294,10 @@ async fn collect( let limit = state.config.max_upload_bytes as u64; let archive = uploads_dir.join(&id); let opened = archive.clone(); - let expanded = tokio::task::spawn_blocking(move || expand_zip(&opened, &uploads_dir, limit)) - .await - .map_err(anyhow::Error::from); + let expanded = + tokio::task::spawn_blocking(move || expand_zip(&opened, &uploads_dir, limit)) + .await + .map_err(anyhow::Error::from); let _ = tokio::fs::remove_file(&archive).await; let (mut files, mut refused, ids) = match expanded? { @@ -328,7 +336,10 @@ async fn collect( continue; } let text = String::from_utf8_lossy(&text).to_string(); - uploads.push(Upload { content: text_content(&path, text), path }); + uploads.push(Upload { + content: text_content(&path, text), + path, + }); continue; } @@ -340,7 +351,10 @@ async fn collect( continue; } written.push(id.clone()); - uploads.push(Upload { path, content: Content::File { id, mime, size } }); + uploads.push(Upload { + path, + content: Content::File { id, mime, size }, + }); } Ok(uploads) @@ -417,7 +431,10 @@ fn unpack( continue; } let text = String::from_utf8_lossy(&text).to_string(); - uploads.push(Upload { content: text_content(&path, text), path }); + uploads.push(Upload { + content: text_content(&path, text), + path, + }); continue; } @@ -450,7 +467,11 @@ fn unpack( } uploads.push(Upload { - content: Content::File { id, mime: media::mime_of(&path, None), size }, + content: Content::File { + id, + mime: media::mime_of(&path, None), + size, + }, path, }); } @@ -486,7 +507,9 @@ pub async fn import( "a folder can only be imported into a space or a folder".into(), )); } - run(&state, Into::Node(Box::new(target)), multipart).await.map(Json) + run(&state, Into::Node(Box::new(target)), multipart) + .await + .map(Json) } /// `POST /api/import` — an export poured back in whole: each top-level @@ -512,7 +535,9 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult, ) -> AppResult<()> { for upload in uploads.iter_mut() { - let was_file = manifest.nodes.get(&upload.path).is_some_and(|m| m.kind == FILE); - let Content::Markdown(text) = &upload.content else { continue }; + let was_file = manifest + .nodes + .get(&upload.path) + .is_some_and(|m| m.kind == FILE); + let Content::Markdown(text) = &upload.content else { + continue; + }; if !was_file { continue; } @@ -693,7 +723,10 @@ async fn build( let mut dir_ids: HashMap = HashMap::new(); let mut dir_paths: HashMap = HashMap::new(); if let Into::Node(target) = into { - siblings.insert(target.id, nodes::sibling_slugs(&mut tx, Some(target.id), None).await?); + siblings.insert( + target.id, + nodes::sibling_slugs(&mut tx, Some(target.id), None).await?, + ); dir_ids.insert(String::new(), target.id); dir_paths.insert(String::new(), prefix); } @@ -764,7 +797,10 @@ async fn build( (id, slug) }; - let path = join(&dir_paths.get(parent_of(dir)).cloned().unwrap_or_default(), &slug); + let path = join( + &dir_paths.get(parent_of(dir)).cloned().unwrap_or_default(), + &slug, + ); dir_ids.insert(dir.clone(), id); dir_paths.insert(dir.clone(), path.clone()); landed.insert(dir.clone(), Landed::Node(format!("/n/{path}"))); @@ -780,7 +816,12 @@ async fn build( let dated = meta(&upload.path); let (created, updated) = dated.map_or((now, now), |m| (m.created_at, m.updated_at)); - if let Content::File { id: media_id, mime, size } = &upload.content { + if let Content::File { + id: media_id, + mime, + size, + } = &upload.content + { let original = manifest .as_ref() .and_then(|m| m.media.get(&upload.path)) @@ -896,7 +937,9 @@ async fn build( let mut cards = 0usize; if let (Into::Root, Some(manifest)) = (into, &manifest) { for (dir, space) in &manifest.spaces { - let Some(&space_id) = dir_ids.get(dir) else { continue }; + let Some(&space_id) = dir_ids.get(dir) else { + continue; + }; cards += boards::restore(&mut tx, space_id, &space.lists, &by_path).await?; if space.role.as_deref() == Some(nodes::DIARY) { // Only where no space has the role yet: there is one diary. @@ -935,16 +978,25 @@ mod tests { }; fn text(path: &str, content: Content) -> Upload { - Upload { path: path.into(), content } + Upload { + path: path.into(), + content, + } } #[test] fn an_export_uploaded_as_a_folder_is_read_from_where_its_manifest_is() { let manifest = r#"{"format": 1, "nodes": {"work/a.md": {"kind": "document", "name": "A", "created_at": 1, "updated_at": 2}}}"#; let mut uploads = vec![ - text("workspace-2026/.workspace.json", Content::Manifest(manifest.into())), + text( + "workspace-2026/.workspace.json", + Content::Manifest(manifest.into()), + ), text("workspace-2026/work/a.md", Content::Markdown("a".into())), - text("workspace-2026/work/deeper/.workspace.json", Content::Manifest("{}".into())), + text( + "workspace-2026/work/deeper/.workspace.json", + Content::Manifest("{}".into()), + ), ]; let found = take_manifest(&mut uploads).unwrap().unwrap(); assert_eq!(found.nodes["work/a.md"].created_at, 1); @@ -956,7 +1008,10 @@ mod tests { #[test] fn a_manifest_from_a_newer_version_is_refused() { - let mut uploads = vec![text(".workspace.json", Content::Manifest(r#"{"format": 99}"#.into()))]; + let mut uploads = vec![text( + ".workspace.json", + Content::Manifest(r#"{"format": 99}"#.into()), + )]; assert!(take_manifest(&mut uploads).is_err()); } @@ -979,18 +1034,27 @@ mod tests { #[test] fn a_zip_unpacks_markdown_to_memory_and_the_rest_to_disk() { let dir = tempfile::tempdir().expect("tempdir"); - let (uploads, skipped, ids) = - expand_zip(&archive(dir.path()), dir.path(), 1 << 20).unwrap_or_else(|(e, _)| panic!("{e:?}")); + let (uploads, skipped, ids) = expand_zip(&archive(dir.path()), dir.path(), 1 << 20) + .unwrap_or_else(|(e, _)| panic!("{e:?}")); assert_eq!(skipped, ["../escape.txt"]); assert_eq!(ids.len(), 1); - let blob = uploads.iter().find(|u| u.path == "notes/blob.bin").expect("the blob"); - let Content::File { id, size, .. } = &blob.content else { panic!("the blob is a file") }; - assert_eq!(*size, 4096); - assert_eq!(std::fs::metadata(dir.path().join(id)).expect("on disk").len(), 4096); - assert!(uploads + let blob = uploads .iter() - .any(|u| u.path == "notes/a.md" && matches!(&u.content, Content::Markdown(t) if t.contains("text")))); + .find(|u| u.path == "notes/blob.bin") + .expect("the blob"); + let Content::File { id, size, .. } = &blob.content else { + panic!("the blob is a file") + }; + assert_eq!(*size, 4096); + assert_eq!( + std::fs::metadata(dir.path().join(id)) + .expect("on disk") + .len(), + 4096 + ); + assert!(uploads.iter().any(|u| u.path == "notes/a.md" + && matches!(&u.content, Content::Markdown(t) if t.contains("text")))); } #[test] @@ -1005,9 +1069,18 @@ mod tests { #[test] fn refuses_paths_that_escape_the_import() { - assert_eq!(normalize_path("docs/readme.md").as_deref(), Some("docs/readme.md")); - assert_eq!(normalize_path("./docs//readme.md").as_deref(), Some("docs/readme.md")); - assert_eq!(normalize_path("docs\\readme.md").as_deref(), Some("docs/readme.md")); + assert_eq!( + normalize_path("docs/readme.md").as_deref(), + Some("docs/readme.md") + ); + assert_eq!( + normalize_path("./docs//readme.md").as_deref(), + Some("docs/readme.md") + ); + assert_eq!( + normalize_path("docs\\readme.md").as_deref(), + Some("docs/readme.md") + ); assert_eq!(normalize_path("../secrets"), None); assert_eq!(normalize_path("docs/../../etc/passwd"), None); assert_eq!(normalize_path("/etc/passwd"), None); @@ -1041,8 +1114,14 @@ mod tests { #[test] fn relative_links_resolve_against_their_document() { - assert_eq!(join_relative("docs", "img/a.png").as_deref(), Some("docs/img/a.png")); - assert_eq!(join_relative("docs/deep", "../a.md").as_deref(), Some("docs/a.md")); + assert_eq!( + join_relative("docs", "img/a.png").as_deref(), + Some("docs/img/a.png") + ); + assert_eq!( + join_relative("docs/deep", "../a.md").as_deref(), + Some("docs/a.md") + ); assert_eq!(join_relative("", "a.md").as_deref(), Some("a.md")); assert_eq!(join_relative("docs", "../../outside.md"), None); } @@ -1053,7 +1132,10 @@ mod tests { assert!(split_target("#section").is_none()); assert!(split_target("/api/media/x").is_none()); assert!(split_target("mailto:someone@example.org").is_none()); - assert_eq!(split_target("a%20b.md#top"), Some(("a b.md".into(), "#top"))); + assert_eq!( + split_target("a%20b.md#top"), + Some(("a b.md".into(), "#top")) + ); } #[test] @@ -1070,8 +1152,14 @@ mod tests { #[test] fn links_point_at_where_the_files_landed() { let landed = HashMap::from([ - ("einarbeiten/01.md".to_string(), Landed::Node("/n/work/einarbeiten/01".into())), - ("einarbeiten/img/a.png".to_string(), Landed::Media("/api/media/uuid".into())), + ( + "einarbeiten/01.md".to_string(), + Landed::Node("/n/work/einarbeiten/01".into()), + ), + ( + "einarbeiten/img/a.png".to_string(), + Landed::Media("/api/media/uuid".into()), + ), ]); let body = "see [one](01.md#ziele) and ![pic](img/a.png), \ @@ -1087,10 +1175,8 @@ mod tests { #[test] fn a_link_from_a_sibling_directory_still_resolves() { - let landed = HashMap::from([( - "a/one.md".to_string(), - Landed::Node("/n/work/a/one".into()), - )]); + let landed = + HashMap::from([("a/one.md".to_string(), Landed::Node("/n/work/a/one".into()))]); let (out, count) = rewrite_links("[x](../a/one.md)", "b", &landed); assert_eq!(count, 1); assert_eq!(out, "[x](/n/work/a/one)"); diff --git a/src/routes/media.rs b/src/routes/media.rs index f124fe1..f56804e 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -25,8 +25,10 @@ use crate::{ /// Media referenced from a document body always looks like `/api/media/`, /// which is how a document claims ownership of the files it embeds. pub(crate) static MEDIA_REF: LazyLock = LazyLock::new(|| { - Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})") - .expect("static regex is valid") + Regex::new( + r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})", + ) + .expect("static regex is valid") }); /// An upload is attacker-controlled content served from our own origin, so the @@ -279,7 +281,13 @@ pub async fn upload_into( // segment, because the folder is the one in the URL. let filename = field .file_name() - .map(|name| name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string()) + .map(|name| { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim() + .to_string() + }) .filter(|name| !name.is_empty()) .unwrap_or_else(|| "upload".to_string()); let mime = mime_of(&filename, field.content_type()); @@ -290,7 +298,17 @@ pub async fn upload_into( continue; } - match insert_file(&state.db, parent, &id, &filename, &mime, size, key.is_some()).await { + match insert_file( + &state.db, + parent, + &id, + &filename, + &mime, + size, + key.is_some(), + ) + .await + { Ok(node) => created.push(node), Err(e) => { let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await; @@ -410,7 +428,10 @@ pub async fn serve( /// `size` bytes. `Ok(None)` serves the whole file: no header, one that cannot /// be read, or several ranges — a whole file is always a valid answer. /// `Err` is a range that starts past the end. -fn byte_range(header: Option<&axum::http::HeaderValue>, size: u64) -> Result, ()> { +fn byte_range( + header: Option<&axum::http::HeaderValue>, + size: u64, +) -> Result, ()> { let Some(spec) = header .and_then(|h| h.to_str().ok()) .and_then(|h| h.trim().strip_prefix("bytes=")) @@ -473,7 +494,11 @@ pub async fn stream_media( // Re-checked on the way out, not just on the way in, so rows written before // the allowlist existed cannot be served as something executable either. let mime = sanitize_mime(&row.get::("mime")); - let disposition = if is_inline(&mime) { "inline" } else { "attachment" }; + let disposition = if is_inline(&mime) { + "inline" + } else { + "attachment" + }; let key = match (encrypted, private) { (false, _) => None, @@ -520,14 +545,16 @@ pub async fn stream_media( // browser's cache long after the vault had been shut. ( header::CACHE_CONTROL, - if encrypted { "no-store" } else { "private, max-age=31536000" }.to_string(), + if encrypted { + "no-store" + } else { + "private, max-age=31536000" + } + .to_string(), ), ( header::CONTENT_DISPOSITION, - format!( - "{disposition}; filename*=UTF-8''{}", - urlencode(&filename) - ), + format!("{disposition}; filename*=UTF-8''{}", urlencode(&filename)), ), // Belt and braces: never let the browser sniff past the type above, // and strip the ambient authority of the origin from the response. @@ -571,8 +598,10 @@ async fn open_stream( let first = start / vault::CHUNK as u64; if first > 0 { opener.skip_to(first)?; - file.seek(std::io::SeekFrom::Start(vault::HEADER as u64 + first * vault::RECORD as u64)) - .await?; + file.seek(std::io::SeekFrom::Start( + vault::HEADER as u64 + first * vault::RECORD as u64, + )) + .await?; } let skip = (start - first * vault::CHUNK as u64) as usize; @@ -589,7 +618,10 @@ async fn open_stream( let piece = &plain[skip.min(plain.len())..]; let piece = &piece[..piece.len().min(usize::try_from(left).unwrap_or(usize::MAX))]; let left = left - piece.len() as u64; - Ok(Some((axum::body::Bytes::copy_from_slice(piece), (file, opener, 0, left)))) + Ok(Some(( + axum::body::Bytes::copy_from_slice(piece), + (file, opener, 0, left), + ))) }, )) } @@ -646,11 +678,7 @@ pub async fn remove_upload(uploads: &std::path::Path, id: &str) { /// Attach every media file the body embeds to this document, and detach the /// ones it no longer mentions so they stop being reachable through its share /// link. Other documents keep whatever they embed; this rewrites one node's rows. -pub async fn link_to_node( - db: &mut SqliteConnection, - node_id: i64, - body: &str, -) -> AppResult<()> { +pub async fn link_to_node(db: &mut SqliteConnection, node_id: i64, body: &str) -> AppResult<()> { let referenced: BTreeSet = MEDIA_REF .captures_iter(body) .map(|c| c[1].to_lowercase()) @@ -736,7 +764,8 @@ mod tests { #[test] fn ranges_are_read_the_way_browsers_send_them() { - let range = |raw: &'static str| byte_range(Some(&axum::http::HeaderValue::from_static(raw)), 1000); + let range = + |raw: &'static str| byte_range(Some(&axum::http::HeaderValue::from_static(raw)), 1000); assert_eq!(byte_range(None, 1000), Ok(None)); assert_eq!(range("bytes=0-"), Ok(Some((0, 999)))); assert_eq!(range("bytes=100-199"), Ok(Some((100, 199)))); @@ -761,7 +790,14 @@ mod tests { #[test] fn keeps_types_that_cannot_execute() { - for mime in ["image/png", "image/jpeg", "video/mp4", "audio/ogg", "text/plain", "application/pdf"] { + for mime in [ + "image/png", + "image/jpeg", + "video/mp4", + "audio/ogg", + "text/plain", + "application/pdf", + ] { assert_eq!(sanitize_mime(mime), mime); } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1bb4c8f..168ca03 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,10 +1,10 @@ pub mod backup; pub mod boards; -pub mod nodes; pub mod export; pub mod import; pub mod manifest; pub mod media; +pub mod nodes; pub mod seal; pub mod session; pub mod share; @@ -35,7 +35,10 @@ pub fn api_router(max_upload: usize) -> Router { Router::new() .route("/health", get(session::health)) .route("/me", get(session::me)) - .route("/login", post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT))) + .route( + "/login", + post(session::login).layer(DefaultBodyLimit::max(LOGIN_LIMIT)), + ) .route("/logout", post(session::logout)) .route("/export", get(export::export)) .route("/backup", get(backup::status).post(backup::run)) @@ -44,9 +47,15 @@ pub fn api_router(max_upload: usize) -> Router { .route("/agenda", get(boards::agenda)) .route("/spaces/{id}/board", get(boards::board)) .route("/spaces/{id}/lists", post(boards::create_list)) - .route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list)) + .route( + "/lists/{id}", + put(boards::rename_list).delete(boards::remove_list), + ) .route("/lists/{id}/cards", post(boards::create_card)) - .route("/cards/{id}", put(boards::update_card).delete(boards::remove_card)) + .route( + "/cards/{id}", + put(boards::update_card).delete(boards::remove_card), + ) .route("/cards/{id}/move", post(boards::move_card)) .route("/nodes", get(nodes::list).post(nodes::create)) .route("/resolve", get(nodes::resolve)) @@ -59,12 +68,20 @@ pub fn api_router(max_upload: usize) -> Router { .route("/nodes/{id}/files", post(media::upload_into).layer(uploads)) .route("/nodes/{id}/encrypt", post(seal::encrypt)) .route("/nodes/{id}/decrypt", post(seal::decrypt)) - .route("/vault", get(vault::status).post(vault::setup).put(vault::change_password)) + .route( + "/vault", + get(vault::status) + .post(vault::setup) + .put(vault::change_password), + ) .route("/vault/unlock", post(vault::unlock)) .route("/vault/lock", post(vault::lock)) .route("/nodes/{id}/share", post(share::enable)) .route("/nodes/{id}/share", delete(share::disable)) - .route("/media", post(media::upload).layer(uploads).get(media::list)) + .route( + "/media", + post(media::upload).layer(uploads).get(media::list), + ) .route("/media/{id}", get(media::serve).delete(media::remove)) .route("/share/{token}/{key}", get(share::read)) .route("/share/{token}/{key}/media/{id}", get(share::serve_media)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 654eb3f..bc8cc84 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -174,7 +174,8 @@ const HEAD_CHARS: usize = 1024; const EXCERPT_CHARS: usize = 180; /// The columns every list query selects, so a row always converts the same way. -const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role, +const SUMMARY_COLUMNS: &str = + "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role, n.created_at, n.updated_at, n.share_token, n.media_id, (SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count, (SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime, @@ -305,9 +306,13 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { .map(|media_id| FileInfo { url: format!("/api/media/{media_id}"), media_id, - mime: row.get::, _>("file_mime").unwrap_or_default(), + mime: row + .get::, _>("file_mime") + .unwrap_or_default(), size: row.get::, _>("file_size").unwrap_or_default(), - encrypted: row.get::, _>("file_encrypted").unwrap_or_default(), + encrypted: row + .get::, _>("file_encrypted") + .unwrap_or_default(), }); NodeSummary { id: row.get("id"), @@ -330,7 +335,10 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { } /// The spaces, in display order. Their child counts are what the switcher shows. -pub async fn spaces(_: Session, State(state): State) -> AppResult>> { +pub async fn spaces( + _: Session, + State(state): State, +) -> AppResult>> { list_spaces(&state.db).await.map(Json) } @@ -358,7 +366,10 @@ pub struct SpaceOverview { /// Every space with the documents last written in it, from any depth: what /// the overview shows, the way the desktop's overview shows each workspace /// with its windows. -pub async fn overview(_: Session, State(state): State) -> AppResult>> { +pub async fn overview( + _: Session, + State(state): State, +) -> AppResult>> { let mut out = Vec::new(); for space in list_spaces(&state.db).await? { let recent = recent_documents(&state.db, space.id, OVERVIEW_RECENT).await?; @@ -368,7 +379,11 @@ pub async fn overview(_: Session, State(state): State) -> AppResult AppResult> { +pub async fn recent_documents( + db: &SqlitePool, + root: i64, + limit: i64, +) -> AppResult> { let rows = sqlx::query(&format!( "WITH RECURSIVE sub (id) AS ( SELECT id FROM nodes WHERE id = ?1 @@ -413,10 +428,12 @@ pub async fn insert_space(db: &SqlitePool, name: &str, has_board: bool) -> AppRe let mut tx = crate::db::begin_write(db).await?; let slug = unique_slug(&mut tx, None, &slugify(name), None).await?; let now = crate::now(); - let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL") - .fetch_one(&mut *tx) - .await? - .get("next"); + let position: i64 = sqlx::query( + "SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL", + ) + .fetch_one(&mut *tx) + .await? + .get("next"); let id: i64 = sqlx::query( "INSERT INTO nodes (parent_id, kind, name, slug, position, has_board, created_at, updated_at) @@ -502,7 +519,13 @@ pub async fn list( for node in &mut found { let path = path_of(&state.db, node.id).await?; let parents = &path[..path.len().saturating_sub(1)]; - node.location = Some(parents.iter().map(|c| c.slug.as_str()).collect::>().join("/")); + node.location = Some( + parents + .iter() + .map(|c| c.slug.as_str()) + .collect::>() + .join("/"), + ); } } Ok(Json(found)) @@ -541,7 +564,9 @@ pub async fn resolve( .ok_or(AppError::NotFound)?; current = Some(row.get("id")); } - load(&state, current.ok_or(AppError::NotFound)?).await.map(Json) + load(&state, current.ok_or(AppError::NotFound)?) + .await + .map(Json) } pub async fn create( @@ -595,13 +620,17 @@ pub async fn update( // here is still free when it is written, and a file's name and its // download name never disagree. let mut tx = crate::db::begin_write(&state.db).await?; - let row = sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1") - .bind(id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; + let row = + sqlx::query("SELECT parent_id, kind, name, slug, updated_at FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or(AppError::NotFound)?; - if input.base_updated_at.is_some_and(|base| row.get::("updated_at") > base) { + if input + .base_updated_at + .is_some_and(|base| row.get::("updated_at") > base) + { return Err(AppError::Conflict( "changed somewhere else since it was opened — :e! takes that version, :w! writes over it".into(), )); @@ -627,13 +656,19 @@ pub async fn update( // A file has no text, only a name, and the name is also what a download // of it is called. - let body = if kind == FILE { "" } else { input.body.as_str() }; + let body = if kind == FILE { + "" + } else { + input.body.as_str() + }; if kind == FILE { - sqlx::query("UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)") - .bind(name) - .bind(id) - .execute(&mut *tx) - .await?; + sqlx::query( + "UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)", + ) + .bind(name) + .bind(id) + .execute(&mut *tx) + .await?; } sqlx::query( @@ -740,7 +775,9 @@ pub async fn move_node( // A space is a root: under a folder it would drop out of the switcher // while its board and its cards carried on as if nothing had happened. if kind == SPACE { - return Err(AppError::BadRequest("a space cannot be moved into anything".into())); + return Err(AppError::BadRequest( + "a space cannot be moved into anything".into(), + )); } container(&mut tx, input.parent_id).await?; @@ -775,10 +812,7 @@ pub async fn move_node( } /// Refuse anything but a space or a folder as the place a node goes into. -pub async fn container( - db: &mut SqliteConnection, - id: i64, -) -> AppResult<()> { +pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> { let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1") .bind(id) .fetch_optional(&mut *db) @@ -794,10 +828,7 @@ pub async fn container( } /// A node and everything beneath it, the node itself first. -pub async fn descendants( - db: &mut SqliteConnection, - id: i64, -) -> AppResult> { +pub async fn descendants(db: &mut SqliteConnection, id: i64) -> AppResult> { let rows = sqlx::query( "WITH RECURSIVE sub (id) AS ( SELECT id FROM nodes WHERE id = ?1 @@ -970,7 +1001,10 @@ mod tests { .connect("sqlite::memory:") .await .expect("an in-memory database"); - sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrations apply"); let insert = |parent: i64, kind: &'static str, slug: &'static str, at: i64| { let db = db.clone(); @@ -1028,7 +1062,12 @@ mod tests { .expect("a database"); let mut before = sqlx::migrate!("./migrations"); - before.migrations = before.migrations.iter().filter(|m| m.version < 10).cloned().collect(); + before.migrations = before + .migrations + .iter() + .filter(|m| m.version < 10) + .cloned() + .collect(); before.run(&db).await.expect("migrations up to 0009"); let folder: i64 = sqlx::query_scalar( @@ -1055,7 +1094,10 @@ mod tests { let mut stale = db.acquire().await.expect("a second connection"); stale.ping().await.expect("ping"); - sqlx::migrate!("./migrations").run(&db).await.expect("0010 applies"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("0010 applies"); let (name, slug, owned): (String, String, i64) = sqlx::query_as( "SELECT n.name, n.slug, (SELECT COUNT(*) FROM node_media nm WHERE nm.node_id = n.id) @@ -1065,12 +1107,16 @@ mod tests { .fetch_one(&db) .await .expect("the adopted file"); - assert_eq!((name.as_str(), slug.as_str(), owned), ("slides.pdf", "file-0a1b2c3d", 1)); - let on_folder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1") - .bind(folder) - .fetch_one(&db) - .await - .expect("count"); + assert_eq!( + (name.as_str(), slug.as_str(), owned), + ("slides.pdf", "file-0a1b2c3d", 1) + ); + let on_folder: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1") + .bind(folder) + .fetch_one(&db) + .await + .expect("count"); assert_eq!(on_folder, 0); db.execute("INSERT INTO media (id, filename, mime, size, created_at) VALUES ('m2', 'b.zip', 'application/octet-stream', 1, 1)") @@ -1095,9 +1141,15 @@ mod tests { .await .is_err()); - let check: String = sqlx::query_scalar("PRAGMA integrity_check").fetch_one(&db).await.expect("check"); + let check: String = sqlx::query_scalar("PRAGMA integrity_check") + .fetch_one(&db) + .await + .expect("check"); assert_eq!(check, "ok"); - let dangling = sqlx::query("PRAGMA foreign_key_check").fetch_all(&db).await.expect("fk check"); + let dangling = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&db) + .await + .expect("fk check"); assert!(dangling.is_empty()); } @@ -1125,8 +1177,14 @@ mod tests { fn fts_query_discards_operators() { // Anything that could be read as FTS syntax must not survive as syntax. assert_eq!(fts_query("hello"), Some("\"hello\"*".into())); - assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into())); - assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into())); + assert_eq!( + fts_query("a OR b"), + Some("\"a\"* AND \"OR\"* AND \"b\"*".into()) + ); + assert_eq!( + fts_query("foo\"NEAR/2\"bar"), + Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()) + ); assert_eq!(fts_query(" "), None); assert_eq!(fts_query("***"), None); } @@ -1155,7 +1213,10 @@ mod tests { #[test] fn a_taken_slug_gets_a_number() { - let taken: BTreeSet = ["readme", "readme-2"].iter().map(|s| s.to_string()).collect(); + let taken: BTreeSet = ["readme", "readme-2"] + .iter() + .map(|s| s.to_string()) + .collect(); assert_eq!(next_slug(&taken, "readme").unwrap(), "readme-3"); assert_eq!(next_slug(&taken, "notes").unwrap(), "notes"); assert_eq!(next_slug(&BTreeSet::new(), "readme").unwrap(), "readme"); diff --git a/src/routes/seal.rs b/src/routes/seal.rs index 4831db9..3b765a5 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -69,7 +69,11 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { if subtree.is_empty() { return Err(AppError::NotFound); } - let list = subtree.iter().map(i64::to_string).collect::>().join(","); + let list = subtree + .iter() + .map(i64::to_string) + .collect::>() + .join(","); let targets: Vec = sqlx::query(&format!( "SELECT n.id, n.media_id, m.encrypted FROM nodes n JOIN media m ON m.id = n.media_id @@ -127,7 +131,12 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { } else { 0 }; - Ok(Resealed { files, unchanged, failed: failed.len(), shared: shared as usize }) + Ok(Resealed { + files, + unchanged, + failed: failed.len(), + shared: shared as usize, + }) } /// Re-write one file sealed or opened under a new id, then point everything @@ -140,8 +149,13 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool let partial = uploads.join(format!("{new}.part")); let written = { - let (key, old, new, source, partial) = - (key.clone(), old.clone(), new.clone(), source.clone(), partial.clone()); + let (key, old, new, source, partial) = ( + key.clone(), + old.clone(), + new.clone(), + source.clone(), + partial.clone(), + ); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { if seal { let mut plain = std::fs::File::open(&source)?; diff --git a/src/routes/session.rs b/src/routes/session.rs index 15ec2ab..d15d0f9 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -9,9 +9,9 @@ use serde_json::json; use crate::{ auth::{self, Session}, - throttle::FREE_ATTEMPTS, error::{AppError, AppResult}, state::AppState, + throttle::FREE_ATTEMPTS, }; #[derive(Deserialize)] @@ -46,7 +46,10 @@ pub async fn login( .unwrap_or("unknown"); state.notify.note_login(user_agent).await; - let token = auth::issue_token(&state.config, state.epoch.load(std::sync::atomic::Ordering::SeqCst)); + let token = auth::issue_token( + &state.config, + state.epoch.load(std::sync::atomic::Ordering::SeqCst), + ); Ok(( StatusCode::OK, [( @@ -98,7 +101,10 @@ pub async fn health(State(state): State) -> impl IntoResponse { Ok(_) => (StatusCode::OK, Json(json!({ "ok": true }))), Err(e) => { tracing::error!("health check: the database does not answer: {e}"); - (StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "ok": false }))) + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "ok": false })), + ) } } } diff --git a/src/routes/ssh_keys.rs b/src/routes/ssh_keys.rs index ea0f49a..460916a 100644 --- a/src/routes/ssh_keys.rs +++ b/src/routes/ssh_keys.rs @@ -30,7 +30,10 @@ pub struct AddInput { pub public_key: String, } -pub async fn list(_: Session, State(state): State) -> AppResult>> { +pub async fn list( + _: Session, + State(state): State, +) -> AppResult>> { let rows = sqlx::query( "SELECT id, name, fingerprint, created_at, last_used_at FROM ssh_keys ORDER BY created_at", ) diff --git a/src/routes/vault.rs b/src/routes/vault.rs index 7082077..d8d5053 100644 --- a/src/routes/vault.rs +++ b/src/routes/vault.rs @@ -99,7 +99,13 @@ pub async fn change_password( ) -> AppResult> { strong_enough(&input.password)?; let attempt = throttle(&state)?; - answer(attempt, state.vault.change_password(input.current, input.password).await?)?; + answer( + attempt, + state + .vault + .change_password(input.current, input.password) + .await?, + )?; tracing::info!("vault password changed"); state.backup.signal(); Ok(Json(state.vault.status().await?)) diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index cf9073c..9db74f4 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -18,9 +18,7 @@ use std::{collections::HashMap, sync::Arc}; -use russh_sftp::protocol::{ - File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version, -}; +use russh_sftp::protocol::{File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version}; use sqlx::{Row, SqlitePool}; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; @@ -300,11 +298,12 @@ impl SpaceFs { /// A name a document or a folder already has, or an earlier file, makes /// the later file carry its id. async fn files_in(&self, parent: i64) -> SftpResult> { - let others = sqlx::query("SELECT kind, slug FROM nodes WHERE parent_id = ?1 AND kind <> 'file'") - .bind(parent) - .fetch_all(&self.db) - .await - .map_err(|_| StatusCode::Failure)?; + let others = + sqlx::query("SELECT kind, slug FROM nodes WHERE parent_id = ?1 AND kind <> 'file'") + .bind(parent) + .fetch_all(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; let mut taken: std::collections::HashSet = others .iter() .map(|row| { @@ -498,9 +497,14 @@ impl SpaceFs { /// An empty document under `parent`, named after the file it was created /// as until a heading says otherwise. async fn create_doc(&mut self, parent: i64, stem: &str) -> SftpResult { - let slug = nodes::unique_slug(&mut *self.conn().await?, Some(parent), &nodes::slugify(stem), None) - .await - .map_err(|_| StatusCode::Failure)?; + let slug = nodes::unique_slug( + &mut *self.conn().await?, + Some(parent), + &nodes::slugify(stem), + None, + ) + .await + .map_err(|_| StatusCode::Failure)?; let id: i64 = sqlx::query( "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) VALUES (?1, 'document', ?2, ?3, '', ?4, ?4) RETURNING id", @@ -577,8 +581,14 @@ impl SpaceFs { if current > base && Some(current) != ours { let parent: i64 = row.get("parent_id"); let slug: String = row.get("slug"); - let copy = self.create_doc(parent, &format!("{slug} (conflict)")).await?; - tracing::warn!(id, copy, "sftp: the document changed elsewhere while open; kept this write as a copy"); + let copy = self + .create_doc(parent, &format!("{slug} (conflict)")) + .await?; + tracing::warn!( + id, + copy, + "sftp: the document changed elsewhere while open; kept this write as a copy" + ); return self.save_doc(copy, buf).await; } self.save_doc(id, buf).await @@ -627,7 +637,15 @@ impl SpaceFs { /// the one it was opened over — under a new id, because the mirror treats /// an upload as never changing once written. async fn finish_upload(&self, upload: Upload) -> SftpResult<()> { - let Upload { parent, name, replaces, media_id, partial, file, size } = upload; + let Upload { + parent, + name, + replaces, + media_id, + partial, + file, + size, + } = upload; let landed = async { file.sync_all().await?; drop(file); @@ -635,7 +653,8 @@ impl SpaceFs { let mime = media::mime_of(&name, None); match replaces { None => { - media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false).await?; + media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false) + .await?; } Some(node) => self.replace_bytes(node, &media_id, &mime, size).await?, } @@ -652,7 +671,13 @@ impl SpaceFs { Ok(()) } - async fn replace_bytes(&self, node: i64, media_id: &str, mime: &str, size: u64) -> AppResult<()> { + async fn replace_bytes( + &self, + node: i64, + media_id: &str, + mime: &str, + size: u64, + ) -> AppResult<()> { let mut tx = crate::db::begin_write(&self.db).await?; let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1") .bind(node) @@ -701,17 +726,35 @@ impl SpaceFs { } /// A scratch file renamed onto a name worth keeping becomes a file node. - async fn keep_scratch(&self, parent: i64, name: &str, data: Vec, replaces: Option<&FileNode>) -> SftpResult<()> { + async fn keep_scratch( + &self, + parent: i64, + name: &str, + data: Vec, + replaces: Option<&FileNode>, + ) -> SftpResult<()> { let mut upload = self.begin_upload(parent, name, replaces, true).await?; - upload.file.write_all(&data).await.map_err(|_| StatusCode::Failure)?; + upload + .file + .write_all(&data) + .await + .map_err(|_| StatusCode::Failure)?; upload.size = data.len() as u64; self.finish_upload(upload).await } /// Move a file node, and rename it when the name it lands under is not /// the one it already shows as. - async fn move_file(&self, file: &FileNode, parent: i64, leaf: &str, shown: &str) -> SftpResult<()> { - let mut tx = crate::db::begin_write(&self.db).await.map_err(|_| StatusCode::Failure)?; + async fn move_file( + &self, + file: &FileNode, + parent: i64, + leaf: &str, + shown: &str, + ) -> SftpResult<()> { + let mut tx = crate::db::begin_write(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; let name: String = if leaf == shown { sqlx::query_scalar("SELECT name FROM nodes WHERE id = ?1") .bind(file.id) @@ -724,15 +767,17 @@ impl SpaceFs { let slug = nodes::unique_slug(&mut tx, Some(parent), &nodes::slugify(&name), Some(file.id)) .await .map_err(|_| StatusCode::Failure)?; - sqlx::query("UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5") - .bind(parent) - .bind(&name) - .bind(&slug) - .bind(crate::now()) - .bind(file.id) - .execute(&mut *tx) - .await - .map_err(|_| StatusCode::Failure)?; + sqlx::query( + "UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5", + ) + .bind(parent) + .bind(&name) + .bind(&slug) + .bind(crate::now()) + .bind(file.id) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::Failure)?; sqlx::query("UPDATE media SET filename = ?1 WHERE id = ?2") .bind(&name) .bind(&file.media_id) @@ -782,7 +827,10 @@ impl SpaceFs { for (key, file) in &self.scratch { if let Some((parent, name)) = key.rsplit_once('/') { if parent == dir || (parent.is_empty() && dir == "/") { - files.push(File::new(name, file_attrs(file.data.len() as u64, file.mtime))); + files.push(File::new( + name, + file_attrs(file.data.len() as u64, file.mtime), + )); } } } @@ -866,7 +914,9 @@ impl russh_sftp::server::Handler for SpaceFs { } // Truncating a file in place is a write; it is done by opening // it for one. - Target::File(file) if size as i64 != file.size => return Err(StatusCode::OpUnsupported), + Target::File(file) if size as i64 != file.size => { + return Err(StatusCode::OpUnsupported) + } Target::Root | Target::Dir { .. } | Target::File(_) => {} } } @@ -894,7 +944,11 @@ impl russh_sftp::server::Handler for SpaceFs { if size > self.max_upload { return Err(StatusCode::Failure); } - upload.file.set_len(size).await.map_err(|_| StatusCode::Failure)?; + upload + .file + .set_len(size) + .await + .map_err(|_| StatusCode::Failure)?; upload.size = size; } Ok(ok_status(id)) @@ -957,9 +1011,19 @@ impl russh_sftp::server::Handler for SpaceFs { return Err(StatusCode::Failure); } let (id, buf, base) = match existing { - Some(doc) if pflags.contains(OpenFlags::TRUNCATE) => (doc.id, Vec::new(), doc.updated_at), - Some(doc) => (doc.id, render_doc(&doc.name, &doc.body).into_bytes(), doc.updated_at), - None => (self.create_doc(parent, stem).await?, Vec::new(), crate::now()), + Some(doc) if pflags.contains(OpenFlags::TRUNCATE) => { + (doc.id, Vec::new(), doc.updated_at) + } + Some(doc) => ( + doc.id, + render_doc(&doc.name, &doc.body).into_bytes(), + doc.updated_at, + ), + None => ( + self.create_doc(parent, stem).await?, + Vec::new(), + crate::now(), + ), }; OpenHandle::Write { id, buf, base } } else { @@ -1001,7 +1065,10 @@ impl russh_sftp::server::Handler for SpaceFs { return Err(StatusCode::Failure); } let truncate = pflags.contains(OpenFlags::TRUNCATE); - OpenHandle::Upload(self.begin_upload(parent, leaf, existing.as_ref(), truncate).await?) + OpenHandle::Upload( + self.begin_upload(parent, leaf, existing.as_ref(), truncate) + .await?, + ) } else { let file = existing.ok_or(StatusCode::NoSuchFile)?; if file.encrypted { @@ -1010,7 +1077,10 @@ impl russh_sftp::server::Handler for SpaceFs { let opened = tokio::fs::File::open(self.uploads.join(&file.media_id)) .await .map_err(|_| StatusCode::NoSuchFile)?; - OpenHandle::ReadFile { file: opened, size: file.size as u64 } + OpenHandle::ReadFile { + file: opened, + size: file.size as u64, + } } } }; @@ -1039,8 +1109,12 @@ impl russh_sftp::server::Handler for SpaceFs { } let want = (len as u64).min(size - offset) as usize; let mut data = vec![0u8; want]; - file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|_| StatusCode::Failure)?; - file.read_exact(&mut data).await.map_err(|_| StatusCode::Failure)?; + file.seek(std::io::SeekFrom::Start(offset)) + .await + .map_err(|_| StatusCode::Failure)?; + file.read_exact(&mut data) + .await + .map_err(|_| StatusCode::Failure)?; return Ok(russh_sftp::protocol::Data { id, data }); } @@ -1082,8 +1156,16 @@ impl russh_sftp::server::Handler for SpaceFs { .checked_add(data.len() as u64) .filter(|&end| end <= self.max_upload) .ok_or(StatusCode::Failure)?; - upload.file.seek(std::io::SeekFrom::Start(offset)).await.map_err(|_| StatusCode::Failure)?; - upload.file.write_all(&data).await.map_err(|_| StatusCode::Failure)?; + upload + .file + .seek(std::io::SeekFrom::Start(offset)) + .await + .map_err(|_| StatusCode::Failure)?; + upload + .file + .write_all(&data) + .await + .map_err(|_| StatusCode::Failure)?; upload.size = upload.size.max(end); Ok(ok_status(id)) } @@ -1093,7 +1175,11 @@ impl russh_sftp::server::Handler for SpaceFs { async fn close(&mut self, id: u32, handle: String) -> SftpResult { match self.handles.remove(&handle) { - Some(OpenHandle::Write { id: node, buf, base }) => self.finish_doc(node, buf, base).await?, + Some(OpenHandle::Write { + id: node, + buf, + base, + }) => self.finish_doc(node, buf, base).await?, Some(OpenHandle::Upload(upload)) => self.finish_upload(upload).await?, Some(OpenHandle::Scratch { path, buf }) => { // Removed while open: like an unlinked file, it is simply gone. @@ -1108,7 +1194,11 @@ impl russh_sftp::server::Handler for SpaceFs { Ok(ok_status(id)) } - async fn remove(&mut self, id: u32, filename: String) -> SftpResult { + async fn remove( + &mut self, + id: u32, + filename: String, + ) -> SftpResult { self.authorized().await?; match self.target(&filename).await? { Target::Doc(doc) => self.delete_node(doc.id).await?, @@ -1141,9 +1231,14 @@ impl russh_sftp::server::Handler for SpaceFs { self.changed(); return Ok(ok_status(id)); }; - let slug = nodes::unique_slug(&mut *self.conn().await?, Some(parent), &nodes::slugify(name), None) - .await - .map_err(|_| StatusCode::Failure)?; + let slug = nodes::unique_slug( + &mut *self.conn().await?, + Some(parent), + &nodes::slugify(name), + None, + ) + .await + .map_err(|_| StatusCode::Failure)?; sqlx::query( "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) VALUES (?1, 'folder', ?2, ?3, ?4, ?4)", @@ -1200,7 +1295,12 @@ impl russh_sftp::server::Handler for SpaceFs { match source { Target::Root => return Err(StatusCode::PermissionDenied), - Target::Dir { id: node, kind, slug, .. } => { + Target::Dir { + id: node, + kind, + slug, + .. + } => { match dest { Some(Target::Dir { id: other, .. }) if other == node => {} Some(_) => return Err(StatusCode::Failure), @@ -1222,10 +1322,14 @@ impl russh_sftp::server::Handler for SpaceFs { } } } - let new_slug = - nodes::unique_slug(&mut *self.conn().await?, new_parent, &nodes::slugify(new_leaf), Some(node)) - .await - .map_err(|_| StatusCode::Failure)?; + let new_slug = nodes::unique_slug( + &mut *self.conn().await?, + new_parent, + &nodes::slugify(new_leaf), + Some(node), + ) + .await + .map_err(|_| StatusCode::Failure)?; // A move that keeps the name keeps the title too. let name = (new_leaf != slug).then_some(new_leaf); sqlx::query( @@ -1248,7 +1352,9 @@ impl russh_sftp::server::Handler for SpaceFs { return Err(StatusCode::PermissionDenied); }; match (doc_slug(new_leaf), dest) { - (_, Some(Target::Dir { .. } | Target::File(_))) => return Err(StatusCode::Failure), + (_, Some(Target::Dir { .. } | Target::File(_))) => { + return Err(StatusCode::Failure) + } // Onto another document: that one takes this one's text // and keeps its own id, so its share link survives. (Some(_), Some(Target::Doc(other))) if other.id != doc.id => { @@ -1307,7 +1413,8 @@ impl russh_sftp::server::Handler for SpaceFs { } Some(Target::Root) | None => {} } - self.move_file(&file, new_parent, new_leaf, &old_leaf).await?; + self.move_file(&file, new_parent, new_leaf, &old_leaf) + .await?; } Target::Scratch(key) => { @@ -1319,11 +1426,20 @@ impl russh_sftp::server::Handler for SpaceFs { // A temp file renamed over a file, or onto a name worth // keeping: what a sync tool's atomic write looks like. (None, Some(Target::File(other))) => { - let data = self.scratch.remove(&key).map(|f| f.data).unwrap_or_default(); - self.keep_scratch(new_parent, new_leaf, data, Some(&other)).await?; + let data = self + .scratch + .remove(&key) + .map(|f| f.data) + .unwrap_or_default(); + self.keep_scratch(new_parent, new_leaf, data, Some(&other)) + .await?; } (None, None) if !is_ephemeral(new_leaf) => { - let data = self.scratch.remove(&key).map(|f| f.data).unwrap_or_default(); + let data = self + .scratch + .remove(&key) + .map(|f| f.data) + .unwrap_or_default(); self.keep_scratch(new_parent, new_leaf, data, None).await?; } (Some(_), Some(Target::File(_))) => return Err(StatusCode::Failure), @@ -1367,7 +1483,9 @@ mod tests { }; use sqlx::sqlite::SqlitePoolOptions; - use super::{doc_slug, normalize, render_doc, segments, split_heading, write_at, SpaceFs, MAX_FILE}; + use super::{ + doc_slug, normalize, render_doc, segments, split_heading, write_at, SpaceFs, MAX_FILE, + }; #[test] fn writes_past_the_limit_fail_instead_of_wrapping() { @@ -1381,7 +1499,10 @@ mod tests { #[test] fn dot_dot_climbs_and_stops_at_the_root() { - assert_eq!(segments("/proj/notes/../ideas/./a.md"), ["proj", "ideas", "a.md"]); + assert_eq!( + segments("/proj/notes/../ideas/./a.md"), + ["proj", "ideas", "a.md"] + ); assert_eq!(normalize("/../../proj"), "/proj"); assert_eq!(normalize(""), "/"); } @@ -1419,7 +1540,10 @@ mod tests { .connect("sqlite::memory:") .await .expect("an in-memory database"); - sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply"); + sqlx::migrate!("./migrations") + .run(&db) + .await + .expect("migrations apply"); SpaceFs::with_db(db, uploads, None) } @@ -1429,13 +1553,21 @@ mod tests { async fn files_are_nodes_on_disk() { let dir = tempfile::tempdir().unwrap(); let mut fs = fs_in(dir.path().to_path_buf()).await; - fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); put(&mut fs, "/proj/slides.pdf", "%PDF one").await; put(&mut fs, "/proj/notes.md", "# Notes\n\ntext").await; assert_eq!(get(&mut fs, "/proj/slides.pdf").await, "%PDF one"); async fn names(fs: &SpaceFs) -> Vec { - let mut names: Vec = fs.listing("/proj").await.unwrap().into_iter().map(|f| f.filename).collect(); + let mut names: Vec = fs + .listing("/proj") + .await + .unwrap() + .into_iter() + .map(|f| f.filename) + .collect(); names.sort(); names } @@ -1447,16 +1579,28 @@ mod tests { let uploads = std::fs::read_dir(dir.path()).unwrap().count(); assert_eq!(uploads, 1, "the old bytes went with the old upload"); - fs.rename(0, "/proj/slides.pdf".into(), "/proj/Lecture 1.pdf".into()).await.expect("rename"); + fs.rename(0, "/proj/slides.pdf".into(), "/proj/Lecture 1.pdf".into()) + .await + .expect("rename"); assert_eq!(names(&fs).await, ["Lecture 1.pdf", "notes.md"]); // A sync tool's temp file, renamed onto the name it was for. put(&mut fs, "/proj/.handout.pdf.Xy12", "%PDF three").await; - fs.rename(0, "/proj/.handout.pdf.Xy12".into(), "/proj/handout.pdf".into()).await.expect("rename"); + fs.rename( + 0, + "/proj/.handout.pdf.Xy12".into(), + "/proj/handout.pdf".into(), + ) + .await + .expect("rename"); assert_eq!(get(&mut fs, "/proj/handout.pdf").await, "%PDF three"); - fs.remove(0, "/proj/Lecture 1.pdf".into()).await.expect("remove"); - fs.remove(0, "/proj/handout.pdf".into()).await.expect("remove"); + fs.remove(0, "/proj/Lecture 1.pdf".into()) + .await + .expect("remove"); + fs.remove(0, "/proj/handout.pdf".into()) + .await + .expect("remove"); assert_eq!(names(&fs).await, ["notes.md"]); assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); } @@ -1466,7 +1610,9 @@ mod tests { #[tokio::test] async fn a_write_over_a_newer_edit_is_kept_as_a_copy() { let mut fs = fs().await; - fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); put(&mut fs, "/proj/plan.md", "# Plan\n\nfirst").await; let handle = fs @@ -1478,21 +1624,34 @@ mod tests { .execute(&fs.db) .await .unwrap(); - fs.write(0, handle.clone(), 0, b"# Plan\n\nfrom sftp".to_vec()).await.unwrap(); + fs.write(0, handle.clone(), 0, b"# Plan\n\nfrom sftp".to_vec()) + .await + .unwrap(); fs.close(0, handle).await.unwrap(); - assert_eq!(get(&mut fs, "/proj/plan.md").await, "# Plan\n\nfrom the browser"); - assert_eq!(get(&mut fs, "/proj/plan-conflict.md").await, "# Plan\n\nfrom sftp"); + assert_eq!( + get(&mut fs, "/proj/plan.md").await, + "# Plan\n\nfrom the browser" + ); + assert_eq!( + get(&mut fs, "/proj/plan-conflict.md").await, + "# Plan\n\nfrom sftp" + ); } #[tokio::test] async fn a_removed_key_ends_the_mount() { let mut fs = fs().await; fs.key_id = Some(42); - assert_eq!(fs.opendir(0, "/".into()).await.err(), Some(StatusCode::PermissionDenied)); + assert_eq!( + fs.opendir(0, "/".into()).await.err(), + Some(StatusCode::PermissionDenied) + ); } - const CREATE: OpenFlags = OpenFlags::WRITE.union(OpenFlags::CREATE).union(OpenFlags::TRUNCATE); + const CREATE: OpenFlags = OpenFlags::WRITE + .union(OpenFlags::CREATE) + .union(OpenFlags::TRUNCATE); async fn put(fs: &mut SpaceFs, path: &str, text: &str) { let handle = fs @@ -1500,7 +1659,9 @@ mod tests { .await .expect("open for writing") .handle; - fs.write(0, handle.clone(), 0, text.as_bytes().to_vec()).await.expect("write"); + fs.write(0, handle.clone(), 0, text.as_bytes().to_vec()) + .await + .expect("write"); fs.close(0, handle).await.expect("close"); } @@ -1510,7 +1671,11 @@ mod tests { .await .expect("open for reading") .handle; - let data = fs.read(0, handle.clone(), 0, u32::MAX).await.map(|d| d.data).unwrap_or_default(); + let data = fs + .read(0, handle.clone(), 0, u32::MAX) + .await + .map(|d| d.data) + .unwrap_or_default(); fs.close(0, handle).await.expect("close"); String::from_utf8(data).expect("utf-8") } @@ -1528,7 +1693,9 @@ mod tests { #[tokio::test] async fn mkdir_at_the_root_makes_a_space_and_a_document_lands_in_it() { let mut fs = fs().await; - fs.mkdir(0, "/Side Project".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/Side Project".into(), FileAttributes::empty()) + .await + .expect("mkdir"); let kind: String = sqlx::query_scalar("SELECT kind FROM nodes WHERE slug = 'side-project'") .fetch_one(&fs.db) .await @@ -1536,18 +1703,31 @@ mod tests { assert_eq!(kind, "space"); // Both the name it was made with and the slug it got resolve. - fs.stat(0, "/Side Project".into()).await.expect("stat by name"); - fs.mkdir(0, "/side-project/Ideas".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.stat(0, "/Side Project".into()) + .await + .expect("stat by name"); + fs.mkdir(0, "/side-project/Ideas".into(), FileAttributes::empty()) + .await + .expect("mkdir"); assert_eq!( - fs.mkdir(0, "/side-project/ideas".into(), FileAttributes::empty()).await.err(), + fs.mkdir(0, "/side-project/ideas".into(), FileAttributes::empty()) + .await + .err(), Some(StatusCode::Failure) ); - put(&mut fs, "/side-project/ideas/My Notes.md", "# First thoughts\n\nhello\n").await; + put( + &mut fs, + "/side-project/ideas/My Notes.md", + "# First thoughts\n\nhello\n", + ) + .await; let (_, name, body) = node(&fs, "my-notes").await; assert_eq!(name, "First thoughts"); assert_eq!(body, "hello\n"); - fs.stat(0, "/side-project/ideas/My Notes.md".into()).await.expect("stat by name"); + fs.stat(0, "/side-project/ideas/My Notes.md".into()) + .await + .expect("stat by name"); let text = get(&mut fs, "/side-project/ideas/my-notes.md").await; assert_eq!(text, "# First thoughts\n\nhello\n"); @@ -1563,18 +1743,28 @@ mod tests { #[tokio::test] async fn a_vim_save_keeps_the_same_document() { let mut fs = fs().await; - fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await; let (id, ..) = node(&fs, "plan").await; // The sequence vim runs with `backupcopy=auto` on a mount. put(&mut fs, "/proj/4913", "").await; - fs.remove(0, "/proj/4913".into()).await.expect("remove probe"); + fs.remove(0, "/proj/4913".into()) + .await + .expect("remove probe"); put(&mut fs, "/proj/.plan.md.swp", "swap").await; - fs.rename(0, "/proj/plan.md".into(), "/proj/plan.md~".into()).await.expect("backup"); + fs.rename(0, "/proj/plan.md".into(), "/proj/plan.md~".into()) + .await + .expect("backup"); put(&mut fs, "/proj/plan.md", "# Plan\n\nv2\n").await; - fs.remove(0, "/proj/plan.md~".into()).await.expect("drop backup"); - fs.remove(0, "/proj/.plan.md.swp".into()).await.expect("drop swap"); + fs.remove(0, "/proj/plan.md~".into()) + .await + .expect("drop backup"); + fs.remove(0, "/proj/.plan.md.swp".into()) + .await + .expect("drop swap"); let (after, name, body) = node(&fs, "plan").await; assert_eq!((after, name.as_str(), body.as_str()), (id, "Plan", "v2\n")); @@ -1588,7 +1778,9 @@ mod tests { #[tokio::test] async fn a_temp_file_renamed_over_a_document_replaces_its_text() { let mut fs = fs().await; - fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await; let (id, ..) = node(&fs, "plan").await; @@ -1596,25 +1788,40 @@ mod tests { let listed = fs.listing("/proj").await.expect("listing"); assert!(listed.iter().any(|f| f.filename == "plan.md.tmp")); - fs.rename(0, "/proj/plan.md.tmp".into(), "/proj/plan.md".into()).await.expect("rename"); + fs.rename(0, "/proj/plan.md.tmp".into(), "/proj/plan.md".into()) + .await + .expect("rename"); let (after, name, body) = node(&fs, "plan").await; - assert_eq!((after, name.as_str(), body.as_str()), (id, "Renamed", "v2\n")); + assert_eq!( + (after, name.as_str(), body.as_str()), + (id, "Renamed", "v2\n") + ); assert!(fs.scratch.is_empty()); } #[tokio::test] async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() { let mut fs = fs().await; - fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); - fs.mkdir(0, "/proj/a".into(), FileAttributes::empty()).await.expect("mkdir"); - fs.mkdir(0, "/proj/a/b".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj".into(), FileAttributes::empty()) + .await + .expect("mkdir"); + fs.mkdir(0, "/proj/a".into(), FileAttributes::empty()) + .await + .expect("mkdir"); + fs.mkdir(0, "/proj/a/b".into(), FileAttributes::empty()) + .await + .expect("mkdir"); - fs.rename(0, "/proj".into(), "/Gig".into()).await.expect("rename space"); + fs.rename(0, "/proj".into(), "/Gig".into()) + .await + .expect("rename space"); let (_, name, _) = node(&fs, "gig").await; assert_eq!(name, "Gig"); assert_eq!( - fs.rename(0, "/gig/a".into(), "/gig/a/b/a".into()).await.err(), + fs.rename(0, "/gig/a".into(), "/gig/a/b/a".into()) + .await + .err(), Some(StatusCode::Failure) ); assert_eq!( @@ -1626,8 +1833,15 @@ mod tests { Some(StatusCode::PermissionDenied) ); - fs.mkdir(0, "/empty".into(), FileAttributes::empty()).await.expect("mkdir"); - fs.rmdir(0, "/empty".into()).await.expect("an empty space can go"); - assert_eq!(fs.rmdir(0, "/gig".into()).await.err(), Some(StatusCode::Failure)); + fs.mkdir(0, "/empty".into(), FileAttributes::empty()) + .await + .expect("mkdir"); + fs.rmdir(0, "/empty".into()) + .await + .expect("an empty space can go"); + assert_eq!( + fs.rmdir(0, "/gig".into()).await.err(), + Some(StatusCode::Failure) + ); } } diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index dc8d346..f80f3b9 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -103,7 +103,9 @@ impl ServerHandler for SshSession { _user: &str, public_key: &PublicKey, ) -> Result { - let fingerprint = public_key.fingerprint(russh::keys::HashAlg::Sha256).to_string(); + let fingerprint = public_key + .fingerprint(russh::keys::HashAlg::Sha256) + .to_string(); let matched = sqlx::query("SELECT id FROM ssh_keys WHERE fingerprint = ?1") .bind(&fingerprint) .fetch_optional(&self.state.db) diff --git a/src/state.rs b/src/state.rs index acdb9ef..b927fb5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,9 @@ use std::sync::{atomic::AtomicI64, Arc}; use sqlx::SqlitePool; -use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault}; +use crate::{ + backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault, +}; #[derive(Clone)] pub struct AppState { diff --git a/src/sweep.rs b/src/sweep.rs index 2e34d8b..ff20d45 100644 --- a/src/sweep.rs +++ b/src/sweep.rs @@ -103,7 +103,9 @@ pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result { } let kind = if name.ends_with(".part") || name.starts_with(".import-") { &mut swept.partial - } else if media::canonical_id(&name).as_deref() == Some(name.as_str()) && !known.contains(&name) { + } else if media::canonical_id(&name).as_deref() == Some(name.as_str()) + && !known.contains(&name) + { &mut swept.stray } else { // Not ours to judge: anything that is not a uuid or a leftover of @@ -160,11 +162,13 @@ mod tests { .await .unwrap(); // `kept` is linked the way a saved document links what it embeds. - sqlx::query("INSERT INTO node_media (node_id, media_id) SELECT id, ?1 FROM nodes WHERE slug = 'a'") - .bind(kept) - .execute(&db) - .await - .unwrap(); + sqlx::query( + "INSERT INTO node_media (node_id, media_id) SELECT id, ?1 FROM nodes WHERE slug = 'a'", + ) + .bind(kept) + .execute(&db) + .await + .unwrap(); let hour_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60); for name in [kept, embedded, forgotten, stray, "x.part", "notes.txt"] { @@ -181,14 +185,23 @@ mod tests { std::fs::write(dir.path().join(fresh), b"x").unwrap(); let swept = run(&db, dir.path()).await.unwrap(); - assert_eq!(swept, Swept { partial: 1, stray: 2, unreferenced: 1 }); + assert_eq!( + swept, + Swept { + partial: 1, + stray: 2, + unreferenced: 1 + } + ); let left: std::collections::BTreeSet = std::fs::read_dir(dir.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); - let expected: std::collections::BTreeSet = - [kept, embedded, "notes.txt", fresh].iter().map(|s| s.to_string()).collect(); + let expected: std::collections::BTreeSet = [kept, embedded, "notes.txt", fresh] + .iter() + .map(|s| s.to_string()) + .collect(); assert_eq!(left, expected); } } diff --git a/src/throttle.rs b/src/throttle.rs index 3955bef..25579fe 100644 --- a/src/throttle.rs +++ b/src/throttle.rs @@ -30,7 +30,10 @@ const MAX_DELAY: i64 = 30; fn delay_after(failures: u32) -> i64 { match failures.checked_sub(FREE_ATTEMPTS) { None | Some(0) => 0, - Some(over) => 1i64.checked_shl(over - 1).unwrap_or(MAX_DELAY).min(MAX_DELAY), + Some(over) => 1i64 + .checked_shl(over - 1) + .unwrap_or(MAX_DELAY) + .min(MAX_DELAY), } } @@ -114,10 +117,16 @@ mod tests { let throttle = LoginThrottle::default(); // Made directly: past the free attempts `begin` would make them wait. for _ in 0..10 { - Attempt { throttle: &throttle }.failed(); + Attempt { + throttle: &throttle, + } + .failed(); } assert!(throttle.begin().is_err()); - Attempt { throttle: &throttle }.succeeded(); + Attempt { + throttle: &throttle, + } + .succeeded(); assert!(throttle.begin().is_ok()); } diff --git a/src/vault.rs b/src/vault.rs index 4f2f2a7..c0b1c59 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -87,7 +87,8 @@ fn random() -> [u8; N] { /// The key the password stands for, at the costs the vault was made with. fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result { - let params = argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?; + let params = + argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?; let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); let mut key = Zeroizing::new([0u8; 32]); argon @@ -99,7 +100,13 @@ fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result { fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec)> { let nonce = random::(); let sealed = Aes256Gcm::new(kek.as_ref().into()) - .encrypt(Nonce::from_slice(&nonce), Payload { msg: master.as_ref(), aad: MASTER_AAD }) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: master.as_ref(), + aad: MASTER_AAD, + }, + ) .map_err(|_| anyhow!("could not seal the master key"))?; Ok((nonce, sealed)) } @@ -108,7 +115,13 @@ fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec)> { fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option { let plain = Zeroizing::new( Aes256Gcm::new(kek.as_ref().into()) - .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad: MASTER_AAD }) + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: sealed, + aad: MASTER_AAD, + }, + ) .ok()?, ); let mut key = Zeroizing::new([0u8; 32]); @@ -133,7 +146,10 @@ impl Sealer { let sealed_key = Aes256Gcm::new(master.as_ref().into()) .encrypt( Nonce::from_slice(&wrap_nonce), - Payload { msg: file_key.as_ref(), aad: &file_aad(media_id) }, + Payload { + msg: file_key.as_ref(), + aad: &file_aad(media_id), + }, ) .map_err(|_| anyhow!("could not seal a file key"))?; @@ -147,7 +163,13 @@ impl Sealer { Aes256Gcm::new(file_key.as_ref().into()), stream_nonce.as_slice().into(), ); - Ok((Self { stream, pending: Vec::with_capacity(CHUNK) }, header)) + Ok(( + Self { + stream, + pending: Vec::with_capacity(CHUNK), + }, + header, + )) } /// Take more plaintext; returns whatever whole records it completes. A @@ -223,15 +245,25 @@ impl Opener { Aes256Gcm::new(master.as_ref().into()) .decrypt( Nonce::from_slice(wrap_nonce), - Payload { msg: sealed_key, aad: &file_aad(media_id) }, + Payload { + msg: sealed_key, + aad: &file_aad(media_id), + }, ) - .map_err(|_| anyhow!("this file was not sealed under this vault, or not as {media_id}"))?, + .map_err(|_| { + anyhow!("this file was not sealed under this vault, or not as {media_id}") + })?, ); let stream = StreamBE32::from_aead( Aes256Gcm::new(file_key.as_slice().into()), stream_nonce.into(), ); - Ok(Self { stream, position: 0, total: remaining, last }) + Ok(Self { + stream, + position: 0, + total: remaining, + last, + }) } /// How many bytes the next record takes on disk; 0 once all are read. @@ -282,12 +314,19 @@ pub struct Reader { impl Reader { pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result { - let mut file = std::fs::File::open(path).with_context(|| format!("could not read {}", path.display()))?; + let mut file = std::fs::File::open(path) + .with_context(|| format!("could not read {}", path.display()))?; let len = file.metadata()?.len(); let mut header = [0u8; HEADER]; - file.read_exact(&mut header).context("sealed file is truncated")?; + file.read_exact(&mut header) + .context("sealed file is truncated")?; let opener = Opener::new(master, media_id, &header, len)?; - Ok(Self { inner: file, opener, buffer: Vec::new(), at: 0 }) + Ok(Self { + inner: file, + opener, + buffer: Vec::new(), + at: 0, + }) } } @@ -312,7 +351,12 @@ impl Read for Reader { /// Seal `source` into `target` under `media_id`, on the calling thread. /// Returns the plaintext size. -pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: &std::path::Path) -> Result { +pub fn seal_file( + master: &Key, + media_id: &str, + source: &mut impl Read, + target: &std::path::Path, +) -> Result { use std::io::Write; let mut out = std::io::BufWriter::new(std::fs::File::create(target)?); let (mut sealer, header) = Sealer::new(master, media_id)?; @@ -374,7 +418,11 @@ pub enum Unlock { impl Vault { pub fn new(db: SqlitePool, idle: Duration) -> Arc { - Arc::new(Self { db, idle, open: Mutex::new(None) }) + Arc::new(Self { + db, + idle, + open: Mutex::new(None), + }) } fn slot(&self) -> std::sync::MutexGuard<'_, Option> { @@ -407,7 +455,10 @@ impl Vault { /// next look, with the key sitting in memory all the while. fn expire(&self) { let mut slot = self.slot(); - if slot.as_ref().is_some_and(|open| open.touched.elapsed() >= self.idle) { + if slot + .as_ref() + .is_some_and(|open| open.touched.elapsed() >= self.idle) + { *slot = None; tracing::info!("vault locked after {} idle", humantime(self.idle)); } @@ -448,9 +499,11 @@ impl Vault { } async fn row(&self) -> Result> { - Ok(sqlx::query("SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1") - .fetch_optional(&self.db) - .await?) + Ok(sqlx::query( + "SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1", + ) + .fetch_optional(&self.db) + .await?) } /// Make the vault, and leave it open. Refuses when there already is one: @@ -486,7 +539,10 @@ impl Vault { return Ok(false); } - *self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() }); + *self.slot() = Some(Unlocked { + key: Arc::new(master), + touched: Instant::now(), + }); Ok(true) } @@ -512,7 +568,10 @@ impl Vault { None => Unlock::NotConfigured, Some(None) => Unlock::WrongPassword, Some(Some(key)) => { - *self.slot() = Some(Unlocked { key: Arc::new(key), touched: Instant::now() }); + *self.slot() = Some(Unlocked { + key: Arc::new(key), + touched: Instant::now(), + }); Unlock::Opened } }) @@ -545,7 +604,10 @@ impl Vault { .bind(sealed) .execute(&self.db) .await?; - *self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() }); + *self.slot() = Some(Unlocked { + key: Arc::new(master), + touched: Instant::now(), + }); Ok(Unlock::Opened) } } @@ -611,7 +673,8 @@ mod tests { let plain: Vec = (0..3 * CHUNK + 17).map(|i| (i % 249) as u8).collect(); let sealed = seal(&master, "id", &plain, CHUNK); for first in 0..4u64 { - let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap(); + let mut opener = + Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap(); opener.skip_to(first).unwrap(); let mut at = HEADER + first as usize * super::RECORD; let mut out = Vec::new(); @@ -622,7 +685,8 @@ mod tests { } assert_eq!(out, plain[first as usize * CHUNK..], "from record {first}"); } - let mut opener = Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap(); + let mut opener = + Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap(); assert!(opener.skip_to(4).is_err()); // A middle record presented where the last one should be does not open. opener.skip_to(3).unwrap(); @@ -639,7 +703,11 @@ mod tests { let sealed = seal(&master, "id", &plain, step); let records = size.div_ceil(CHUNK).max(1); assert_eq!(sealed.len(), HEADER + size + records * TAG, "size {size}"); - assert_eq!(open(&master, "id", &sealed).expect("opens"), plain, "size {size} step {step}"); + assert_eq!( + open(&master, "id", &sealed).expect("opens"), + plain, + "size {size} step {step}" + ); } } } @@ -652,7 +720,10 @@ mod tests { let path = dir.path().join("x"); std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write"); let mut out = Vec::new(); - Reader::open(&master, "x", &path).expect("reader").read_to_end(&mut out).expect("read"); + Reader::open(&master, "x", &path) + .expect("reader") + .read_to_end(&mut out) + .expect("read"); assert_eq!(out, plain); } @@ -663,12 +734,18 @@ mod tests { let sealed = seal(&master, "a", &plain, CHUNK); assert!(open(&key(3), "a", &sealed).is_err(), "another vault"); - assert!(open(&master, "b", &sealed).is_err(), "renamed to another id"); + assert!( + open(&master, "b", &sealed).is_err(), + "renamed to another id" + ); // Cut at a record boundary: what is left still parses as records, and // only the missing "last" mark gives it away. let cut = &sealed[..HEADER + CHUNK + TAG]; - assert!(open(&master, "a", cut).is_err(), "truncated at a record boundary"); + assert!( + open(&master, "a", cut).is_err(), + "truncated at a record boundary" + ); let mut flipped = sealed.clone(); flipped[HEADER + 10] ^= 1; @@ -681,7 +758,10 @@ mod tests { let kek = derive("correct horse", b"saltsaltsaltsalt", 256, 1, 1).expect("derive"); let master = key(5); let (nonce, sealed) = seal_master(&kek, &master).expect("seal"); - assert_eq!(open_master(&kek, &nonce, &sealed).expect("opens").as_ref(), master.as_ref()); + assert_eq!( + open_master(&kek, &nonce, &sealed).expect("opens").as_ref(), + master.as_ref() + ); let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive"); assert!(open_master(&wrong, &nonce, &sealed).is_none()); }