split: server foundation (Cargo, config, errors, utils, main)

Cargo.{toml,lock} bumps, build.rs, config-e2e.yml, rust-toolchain.toml,
src/config/* (database/logging/server/user configs), src/consts.rs,
src/errors.rs, src/main.rs, and src/utils/* (dedup_paths,
find_first_available_path, rotating_file_writer, sanitize_path).
This commit is contained in:
Andras Schmelczer 2026-05-08 21:35:18 +01:00
commit a9ce09b59d
4 changed files with 535 additions and 99 deletions

View file

@ -1,8 +1,17 @@
use std::sync::LazyLock;
use regex::Regex;
static DEDUP_SUFFIX_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r" \((\d+)\)$").expect("invalid regex"));
pub fn dedup_paths(path: &str) -> impl Iterator<Item = String> {
let mut path_parts = path.split('/').collect::<Vec<_>>();
let file_name = path_parts.pop().unwrap().to_owned();
let file_name = path_parts
.pop()
.filter(|s| !s.is_empty())
.unwrap_or(path)
.to_owned();
let mut directory = path_parts.join("/");
if !directory.is_empty() {
@ -29,14 +38,13 @@ pub fn dedup_paths(path: &str) -> impl Iterator<Item = String> {
}
};
let regex = Regex::new(r" \((\d+)\)$").unwrap();
let start_number = regex
let start_number = DEDUP_SUFFIX_REGEX
.captures(&stem)
.and_then(|caps| caps.get(1))
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(0);
let clean_stem = regex.replace(&stem, "").to_string();
let clean_stem = DEDUP_SUFFIX_REGEX.replace(&stem, "").to_string();
(start_number..).map(move |dedup_number| {
if dedup_number == 0 {

View file

@ -1,25 +1,30 @@
use crate::app_state::database::models::VaultId;
use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths};
use crate::utils::dedup_paths::dedup_paths;
use anyhow::Result;
use log::{debug, info};
use sqlx::sqlite::SqliteConnection;
pub async fn find_first_available_path(
vault_id: &VaultId,
sanitized_relative_path: &str,
database: &crate::app_state::database::Database,
transaction: &mut Transaction<'_>,
connection: &mut SqliteConnection,
) -> Result<String> {
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
for candidate in dedup_paths(sanitized_relative_path) {
debug!("Checking candidate path for deconflicting names: `{candidate}`");
if database
.get_latest_document_by_path(vault_id, &candidate, Some(transaction))
.get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(connection))
.await?
.is_none()
{
info!("Selected available path: `{candidate}`");
return Ok(candidate);
}
info!(
"Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken"
);
}
unreachable!("dedup_paths produces infinite paths");

View file

@ -6,7 +6,7 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
use chrono::{Local, NaiveDateTime};
use chrono::NaiveDateTime;
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone)]
@ -55,7 +55,7 @@ impl RotatingFileWriter {
let timestamp_str = filename.get(prefix_len..filename.len().checked_sub(4)?)?;
let dt = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").ok()?;
let timestamp = dt.and_local_timezone(Local).single()?;
let timestamp = dt.and_utc();
let secs: u64 = timestamp.timestamp().try_into().ok()?;
Some(UNIX_EPOCH + Duration::from_secs(secs))
@ -114,7 +114,7 @@ impl RotatingFileWriter {
}
fn rotate(inner: &mut RotatingFileWriterInner) -> io::Result<()> {
let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S");
let timestamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%S");
let filename = format!("{}.{}.log", inner.file_prefix, timestamp);
let filepath = inner.directory.join(filename);
@ -132,8 +132,14 @@ impl RotatingFileWriter {
impl Write for RotatingFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut inner = self.inner.lock().unwrap();
let mut inner = self.inner.lock().unwrap_or_else(|poisoned| {
eprintln!("RotatingFileWriter mutex was poisoned, recovering");
poisoned.into_inner()
});
// Reset file handle after poison recovery so the next branch
// re-opens a valid file rather than writing to a potentially
// half-closed handle.
if inner.current_file.is_none() {
Self::open_or_create_log_file(&mut inner)?;
} else if Self::should_rotate(&inner) {
@ -148,7 +154,10 @@ impl Write for RotatingFileWriter {
}
fn flush(&mut self) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
let mut inner = self.inner.lock().unwrap_or_else(|poisoned| {
eprintln!("RotatingFileWriter mutex was poisoned, recovering");
poisoned.into_inner()
});
if let Some(ref mut file) = inner.current_file {
file.flush()
} else {
@ -267,7 +276,7 @@ mod tests {
// Parse the expected time
let expected_dt =
NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").unwrap();
let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap();
let expected_timestamp = expected_dt.and_utc();
let expected_duration =
Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap());
let expected_next = UNIX_EPOCH + expected_duration + rotation_duration;
@ -306,7 +315,7 @@ mod tests {
// Should use the latest file (2025-10-26_14-00-00)
let expected_dt =
NaiveDateTime::parse_from_str("2025-10-26_14-00-00", "%Y-%m-%d_%H-%M-%S").unwrap();
let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap();
let expected_timestamp = expected_dt.and_utc();
let expected_duration =
Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap());
let expected_next = UNIX_EPOCH + expected_duration + rotation_duration;

View file

@ -1,14 +1,28 @@
use anyhow::{Result, ensure};
use crate::consts::MAX_RELATIVE_PATH_LEN;
/// Sanitize the document's path to allow all clients to create the same path in
/// their filesystem. If we didn't do this server-side, client's would need to
/// deal with mapping invalid names to valid ones and then back.
pub fn sanitize_path(path: &str) -> String {
pub fn sanitize_path(path: &str) -> Result<String> {
// Enforce the length cap at the single chokepoint every create/update
// handler goes through, so clients can't blow up axum's JSON/multipart
// parser with a 1 MB `relative_path` before the handler ever runs.
// The WebSocket cursor handler enforces this separately.
ensure!(
path.len() <= MAX_RELATIVE_PATH_LEN,
"Relative path exceeds the maximum length of {MAX_RELATIVE_PATH_LEN} bytes"
);
let options = sanitize_filename::Options {
truncate: true,
windows: true, // Windows is the lowest common denominator
replacement: "",
};
path.split('/')
let result = path
.split('/')
.map(|part| {
let proposal = sanitize_filename::sanitize_with_options(part, options.clone());
if !part.is_empty() && proposal.is_empty() {
@ -18,7 +32,13 @@ pub fn sanitize_path(path: &str) -> String {
}
})
.collect::<Vec<_>>()
.join("/")
.join("/");
ensure!(
!result.is_empty(),
"Relative path is empty after sanitization"
);
Ok(result)
}
#[cfg(test)]
@ -27,8 +47,32 @@ mod test {
#[test]
fn test_sanitize_path() {
assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what");
assert_eq!(sanitize_path("file (1).md"), "file (1).md");
assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_");
assert_eq!(sanitize_path("/my/path/what?").unwrap(), "/my/path/what");
assert_eq!(sanitize_path("file (1).md").unwrap(), "file (1).md");
assert_eq!(sanitize_path("/my/path/\\\\:?").unwrap(), "/my/path/_");
}
#[test]
fn test_sanitize_path_empty() {
assert!(sanitize_path("").is_err());
}
#[test]
fn test_sanitize_path_idempotent_simple() {
let mut result = sanitize_path("notes/my file.md").unwrap();
for _ in 0..5 {
result = sanitize_path(&result).unwrap();
}
assert_eq!(result, "notes/my file.md");
}
#[test]
fn test_sanitize_path_idempotent_special_chars() {
let first = sanitize_path("/my/path/what?/file:name<>.md").unwrap();
let mut result = first.clone();
for _ in 0..5 {
result = sanitize_path(&result).unwrap();
}
assert_eq!(result, first);
}
}