Review pass

This commit is contained in:
Andras Schmelczer 2026-09-19 19:51:08 +01:00
commit 99fa298b92
25 changed files with 621 additions and 94 deletions

View file

@ -2135,8 +2135,10 @@ dependencies = [
"serde_yaml", "serde_yaml",
"sha2", "sha2",
"sqlx", "sqlx",
"tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View file

@ -36,6 +36,10 @@ ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl", "no-serde-w
base64 = "0.22.1" base64 = "0.22.1"
reconcile-text = { version = "0.8.0", features = ["serde"] } reconcile-text = { version = "0.8.0", features = ["serde"] }
[dev-dependencies]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true

View file

@ -93,6 +93,23 @@ endpoint uses the same history, so losing the final notification is recoverable.
A fresh client reads `/vault_snapshot`, applies it durably, then replays after its A fresh client reads `/vault_snapshot`, applies it durably, then replays after its
watermark. watermark.
Cursor notifications are ephemeral and do not consume event IDs. Cursor notifications are ephemeral and do not consume event IDs.
Cursor expiry and disconnect broadcast the remaining clients, including an empty
list when the last client leaves.
## Vault names and database files
Vault names and configured allowlists are trimmed and lowercased consistently.
Empty names, `.` and `..`, path separators, and control characters are rejected.
Other Unicode spellings remain distinct identities, including composed and
decomposed characters.
Databases use `vaults/<sha256-of-normalized-vault-name>.sqlite` underneath the
configured database directory. This prevents filesystem case and Unicode aliases
from sharing a database. This is the only supported storage layout; top-level
`<vault-name>.sqlite` files are not loaded or migrated. Back up the entire database
directory, including the `vaults` subdirectory.
Vault snapshots query metadata and byte lengths without loading document contents.
SQLite uses WAL, `synchronous=FULL`, and `fullfsync` where supported. On Unix, startup also SQLite uses WAL, `synchronous=FULL`, and `fullfsync` where supported. On Unix, startup also
flushes database directory entries; the filesystem must support those durability flushes database directory entries; the filesystem must support those durability

View file

@ -39,7 +39,9 @@ impl Cursors {
) { ) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await; let mut vault_to_cursors = self.vault_to_cursors.lock().await;
let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new); let all_device_cursors = vault_to_cursors
.entry(vault_id.clone())
.or_insert_with(Vec::new);
all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id);
all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors {
@ -48,8 +50,7 @@ impl Cursors {
documents_with_cursors: document_to_cursors, documents_with_cursors: document_to_cursors,
})); }));
drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock self.broadcast_cursors(&vault_id, all_device_cursors).await;
self.broadcast_cursors().await;
} }
pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec<ClientCursors> { pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec<ClientCursors> {
@ -78,31 +79,42 @@ impl Cursors {
async fn remove_expired_cursors(&self) { async fn remove_expired_cursors(&self) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await; let mut vault_to_cursors = self.vault_to_cursors.lock().await;
for (_vault_id, cursors) in vault_to_cursors.iter_mut() { for (vault_id, cursors) in vault_to_cursors.iter_mut() {
let previous_len = cursors.len();
cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout));
if cursors.len() != previous_len {
self.broadcast_cursors(vault_id, cursors).await;
} }
} }
vault_to_cursors.retain(|_, cursors| !cursors.is_empty());
}
async fn broadcast_cursors(&self) { async fn broadcast_cursors(&self, vault_id: &str, cursors: &[ClientCursorsWithTimeToLive]) {
let vault_to_cursors = self.vault_to_cursors.lock().await;
for (vault_id, cursors) in vault_to_cursors.iter() {
self.broadcasts self.broadcasts
.send( .send(
vault_id.clone(), vault_id.to_owned(),
Notification::Cursors(CursorPositionFromServer { Notification::Cursors(CursorPositionFromServer {
clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(), clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(),
}), }),
) )
.await; .await;
} }
}
pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) { pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) {
let mut vault_to_cursors = self.vault_to_cursors.lock().await; let mut vault_to_cursors = self.vault_to_cursors.lock().await;
if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { if let Some(cursors) = vault_to_cursors.get_mut(vault_id) {
let previous_len = cursors.len();
cursors.retain(|c| c.client_cursors.device_id != device_id); cursors.retain(|c| c.client_cursors.device_id != device_id);
if cursors.len() != previous_len {
self.broadcast_cursors(vault_id, cursors).await;
}
if cursors.is_empty() {
vault_to_cursors.remove(vault_id);
}
} }
} }
} }
@ -125,3 +137,96 @@ impl ClientCursorsWithTimeToLive {
self.last_updated.elapsed() > ttl self.last_updated.elapsed() > ttl
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::config::server_config::ServerConfig;
#[tokio::test]
async fn disconnect_broadcasts_remaining_clients_and_the_final_empty_state() {
let broadcasts = Broadcasts::new(&ServerConfig::default());
let cursors = Cursors::new(&DatabaseConfig::default(), &broadcasts);
for device in ["one", "two"] {
cursors
.update_cursors(
"vault".to_owned(),
"user".to_owned(),
&device.to_owned(),
vec![],
)
.await;
}
let mut receiver = broadcasts.get_receiver("vault".to_owned()).await;
let mut other = broadcasts.get_receiver("other".to_owned()).await;
cursors.remove_cursors_of_device("vault", "one").await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert_eq!(update.clients.len(), 1);
assert_eq!(update.clients[0].device_id, "two");
cursors.remove_cursors_of_device("vault", "two").await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert!(update.clients.is_empty());
assert!(other.try_recv().is_err());
cursors.remove_cursors_of_device("vault", "two").await;
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn expiration_broadcasts_only_changed_vaults_including_empty_ones() {
let broadcasts = Broadcasts::new(&ServerConfig::default());
let config = DatabaseConfig::default();
let cursors = Cursors::new(&config, &broadcasts);
for (vault, device) in [("vault", "expired"), ("vault", "live"), ("other", "live")] {
cursors
.update_cursors(
vault.to_owned(),
"user".to_owned(),
&device.to_owned(),
vec![],
)
.await;
}
cursors
.vault_to_cursors
.lock()
.await
.get_mut("vault")
.unwrap()[0]
.last_updated = std::time::Instant::now()
.checked_sub(config.cursor_timeout + Duration::from_secs(1))
.unwrap();
let mut receiver = broadcasts.get_receiver("vault".to_owned()).await;
let mut other = broadcasts.get_receiver("other".to_owned()).await;
cursors.remove_expired_cursors().await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert_eq!(update.clients.len(), 1);
assert_eq!(update.clients[0].device_id, "live");
assert!(other.try_recv().is_err());
cursors
.vault_to_cursors
.lock()
.await
.get_mut("vault")
.unwrap()[0]
.last_updated = std::time::Instant::now()
.checked_sub(config.cursor_timeout + Duration::from_secs(1))
.unwrap();
cursors.remove_expired_cursors().await;
let Notification::Cursors(update) = receiver.try_recv().unwrap() else {
panic!("expected cursors")
};
assert!(update.clients.is_empty());
assert!(cursors.get_cursors(&"vault".to_owned()).await.is_empty());
cursors.remove_expired_cursors().await;
assert!(receiver.try_recv().is_err());
assert!(other.try_recv().is_err());
}
}

View file

@ -1,9 +1,14 @@
use core::time::Duration; use core::time::Duration;
use std::{collections::HashMap, sync::Arc}; use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use log::info; use log::info;
use models::VaultId; use models::VaultId;
use sha2::{Digest, Sha256};
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions}; use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -14,7 +19,15 @@ pub mod models;
mod mutations; mod mutations;
mod queries; mod queries;
use crate::config::database_config::DatabaseConfig; #[cfg(test)]
mod tests;
use crate::{
config::database_config::DatabaseConfig,
utils::normalize_vault_id::{normalize_string, validate_vault_id},
};
const VAULT_DATABASE_DIRECTORY: &str = "vaults";
#[derive(Clone)] #[derive(Clone)]
struct PoolWithTimestamp { struct PoolWithTimestamp {
@ -41,7 +54,10 @@ pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
impl Database { impl Database {
pub async fn try_new(config: &DatabaseConfig) -> Result<Self> { pub async fn try_new(config: &DatabaseConfig) -> Result<Self> {
tokio::fs::create_dir_all(&config.databases_directory_path) let vault_directory = config
.databases_directory_path
.join(VAULT_DATABASE_DIRECTORY);
tokio::fs::create_dir_all(&vault_directory)
.await .await
.with_context(|| { .with_context(|| {
format!( format!(
@ -50,34 +66,25 @@ impl Database {
) )
})?; })?;
let mut connection_pools = std::collections::HashMap::new();
info!("Applying pending database migrations"); info!("Applying pending database migrations");
let mut entries = tokio::fs::read_dir(&config.databases_directory_path).await?;
// Filenames encode only a digest, so their vault names cannot be
// recovered here. Check migrations now and open pools lazily by name.
let mut entries = tokio::fs::read_dir(&vault_directory).await?;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
if !entry.file_name().to_string_lossy().ends_with(".sqlite") { if entry.file_type().await?.is_file()
continue; && entry.path().extension().is_some_and(|ext| ext == "sqlite")
{
Self::open_database(config, &entry.path())
.await?
.close()
.await;
} }
let vault: VaultId = entry
.file_name()
.to_string_lossy()
.trim_end_matches(".sqlite")
.to_owned();
let pool = Self::create_vault_database(config, &vault).await?;
connection_pools.insert(
vault.clone(),
PoolWithTimestamp {
pool,
last_accessed: Instant::now(),
},
);
} }
let database = Self { let database = Self {
config: config.clone(), config: config.clone(),
connection_pools: Arc::new(Mutex::new(connection_pools)), connection_pools: Arc::default(),
}; };
// Start background task to cleanup idle connection pools // Start background task to cleanup idle connection pools
@ -86,16 +93,21 @@ impl Database {
Ok(database) Ok(database)
} }
async fn create_vault_database( fn database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
config: &DatabaseConfig, // Hash the normalized vault name into a fixed-length ASCII filename so
vault: &VaultId, // filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
) -> Result<Pool<Sqlite>> { // macOS) cannot make separately authorized vaults share a database.
let file_name = config // This is only a storage filename; the user-facing vault name is unchanged.
let digest = Sha256::digest(vault.as_bytes());
config
.databases_directory_path .databases_directory_path
.join(format!("{vault}.sqlite")); .join(VAULT_DATABASE_DIRECTORY)
.join(format!("{digest:x}.sqlite"))
}
async fn open_database(config: &DatabaseConfig, file_name: &Path) -> Result<Pool<Sqlite>> {
let connection_options = SqliteConnectOptions::new() let connection_options = SqliteConnectOptions::new()
.filename(file_name.clone()) .filename(file_name)
.create_if_missing(true) .create_if_missing(true)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full) .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full)
.busy_timeout(Duration::from_secs(3600)) .busy_timeout(Duration::from_secs(3600))
@ -126,10 +138,13 @@ impl Database {
} }
async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> { async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> {
let vault = normalize_string(vault);
validate_vault_id(&vault)?;
let mut pools = self.connection_pools.lock().await; let mut pools = self.connection_pools.lock().await;
if !pools.contains_key(vault) { if !pools.contains_key(&vault) {
let pool = Self::create_vault_database(&self.config, vault).await?; let file_name = Self::database_path(&self.config, &vault);
let pool = Self::open_database(&self.config, &file_name).await?;
pools.insert( pools.insert(
vault.clone(), vault.clone(),
PoolWithTimestamp { PoolWithTimestamp {
@ -140,7 +155,7 @@ impl Database {
} }
let pool_with_timestamp = pools let pool_with_timestamp = pools
.get_mut(vault) .get_mut(&vault)
.expect("Pool was just inserted or already exists"); .expect("Pool was just inserted or already exists");
// Update last accessed time // Update last accessed time

View file

@ -22,16 +22,18 @@ pub struct StoredDocumentVersion {
pub device_id: DeviceId, pub device_id: DeviceId,
} }
#[derive(TS, Debug, Clone, Serialize, Deserialize)] #[derive(TS, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct DocumentVersionWithoutContent { pub struct DocumentVersionWithoutContent {
#[ts(as = "f64")] #[ts(as = "f64")]
pub vault_update_id: VaultUpdateId, pub vault_update_id: VaultUpdateId,
#[sqlx(try_from = "uuid::fmt::Hyphenated")]
pub document_id: DocumentId, pub document_id: DocumentId,
pub updated_date: DateTime<Utc>, pub updated_date: DateTime<Utc>,
pub user_id: UserId, pub user_id: UserId,
pub device_id: DeviceId, pub device_id: DeviceId,
#[sqlx(try_from = "i64")]
pub content_size: usize, pub content_size: usize,
} }

View file

@ -1,8 +1,8 @@
use super::{ use super::{
Database, Transaction, Database, Transaction,
models::{ models::{
DocumentId, EventBatch, FileManifest, StoredDocumentVersion, VaultId, VaultSnapshot, DocumentId, DocumentVersionWithoutContent, EventBatch, FileManifest, StoredDocumentVersion,
VaultUpdateId, VaultId, VaultSnapshot, VaultUpdateId,
}, },
}; };
use anyhow::{Result, ensure}; use anyhow::{Result, ensure};
@ -99,8 +99,10 @@ impl Database {
let head_event_id = Self::latest_event_id(&mut tx).await?; let head_event_id = Self::latest_event_id(&mut tx).await?;
let file_manifest = Self::current_file_manifest(&mut tx).await?; let file_manifest = Self::current_file_manifest(&mut tx).await?;
let versions = sqlx::query_as::<_, StoredDocumentVersion>( let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
"SELECT d.* FROM latest_document_versions d "SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id,
length(d.content) AS content_size
FROM latest_document_versions d
JOIN file_manifest_entries m ON m.document_id = d.document_id JOIN file_manifest_entries m ON m.document_id = d.document_id
WHERE m.file_manifest_id = ? ORDER BY d.vault_update_id", WHERE m.file_manifest_id = ? ORDER BY d.vault_update_id",
) )
@ -108,8 +110,6 @@ impl Database {
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
let documents = versions.into_iter().map(Into::into).collect();
Ok(VaultSnapshot { Ok(VaultSnapshot {
head_event_id, head_event_id,
file_manifest, file_manifest,

View file

@ -0,0 +1,160 @@
use super::*;
use models::{DocumentId, EventRecord, FileManifest, StoredDocumentVersion, VaultEvent};
fn config(directory: &tempfile::TempDir) -> DatabaseConfig {
DatabaseConfig {
databases_directory_path: directory.path().to_owned(),
..Default::default()
}
}
async fn insert_content(
database: &Database,
vault: &str,
document_id: DocumentId,
content: &[u8],
) -> StoredDocumentVersion {
let mut tx = database
.create_write_transaction(&vault.to_owned())
.await
.unwrap();
let request_id = uuid::Uuid::new_v4();
let version = StoredDocumentVersion {
vault_update_id: Database::allocate_event(&mut tx, request_id, b"test")
.await
.unwrap(),
document_id,
updated_date: chrono::Utc::now(),
content: content.to_vec(),
user_id: "user".to_owned(),
device_id: "device".to_owned(),
};
Database::insert_document_version(&mut tx, &version)
.await
.unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: version.vault_update_id,
request_id,
event: VaultEvent::Content {
document: (&version).into(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap();
version
}
#[tokio::test]
async fn unicode_vaults_remain_isolated_after_reopening() {
let directory = tempfile::tempdir().unwrap();
let config = config(&directory);
let database = Database::try_new(&config).await.unwrap();
let document = uuid::Uuid::new_v4();
let names = ["café", "cafe\u{301}", "σ", "ς"];
for name in names {
assert!(
database
.get_latest_document_version(&name.to_owned(), &document, None)
.await
.unwrap()
.is_none()
);
insert_content(&database, name, document, name.as_bytes()).await;
}
let reopened = Database::try_new(&config).await.unwrap();
for name in names {
let version = reopened
.get_latest_document_version(&name.to_owned(), &document, None)
.await
.unwrap()
.unwrap();
assert_eq!(version.content, name.as_bytes());
}
}
#[tokio::test]
async fn rejects_invalid_vault_names_at_the_database_boundary() {
let directory = tempfile::tempdir().unwrap();
let config = DatabaseConfig {
databases_directory_path: directory.path().join("databases"),
..Default::default()
};
let database = Database::try_new(&config).await.unwrap();
for name in [
"",
" ",
".",
"..",
"../escaped",
"/absolute",
"a/b",
"a\\b",
"a\0b",
] {
assert!(
database.vault_snapshot(&name.to_owned()).await.is_err(),
"accepted {name:?}"
);
}
assert!(!directory.path().join("escaped.sqlite").exists());
assert!(database.connection_pools.lock().await.is_empty());
}
#[tokio::test]
async fn snapshot_returns_latest_referenced_metadata_and_byte_lengths() {
let directory = tempfile::tempdir().unwrap();
let database = Database::try_new(&config(&directory)).await.unwrap();
let vault = "snapshot".to_owned();
let text_id = uuid::Uuid::new_v4();
let binary_id = uuid::Uuid::new_v4();
insert_content(&database, &vault, text_id, b"old").await;
let binary = insert_content(&database, &vault, binary_id, &[0, 255, 0, 128]).await;
let text = insert_content(&database, &vault, text_id, "café 🌍".as_bytes()).await;
insert_content(&database, &vault, uuid::Uuid::new_v4(), b"unreferenced").await;
let mut tx = database.create_write_transaction(&vault).await.unwrap();
let request_id = uuid::Uuid::new_v4();
let manifest = FileManifest {
file_manifest_id: Database::allocate_event(&mut tx, request_id, b"manifest")
.await
.unwrap(),
entries: [
(text_id, "text.md".to_owned()),
(binary_id, "binary.dat".to_owned()),
]
.into(),
};
Database::insert_file_manifest(&mut tx, &manifest)
.await
.unwrap();
Database::write_event(
&mut tx,
&EventRecord {
event_id: manifest.file_manifest_id,
request_id,
event: VaultEvent::FileManifest {
file_manifest: manifest.clone(),
},
},
)
.await
.unwrap();
tx.commit().await.unwrap();
let snapshot = database.vault_snapshot(&vault).await.unwrap();
assert_eq!(snapshot.head_event_id, manifest.file_manifest_id);
assert_eq!(snapshot.file_manifest.entries, manifest.entries);
assert_eq!(snapshot.documents.len(), 2);
for (actual, expected) in snapshot.documents.iter().zip([binary, text]) {
assert_eq!(
serde_json::to_value(actual).unwrap(),
serde_json::to_value(models::DocumentVersionWithoutContent::from(expected)).unwrap()
);
}
}

View file

@ -7,7 +7,7 @@ use crate::consts::{
DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS, DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS,
}; };
#[derive(Debug, Deserialize, Serialize, Clone, Default)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig { pub struct ServerConfig {
#[serde(default = "default_host")] #[serde(default = "default_host")]
pub host: String, pub host: String,
@ -28,6 +28,19 @@ pub struct ServerConfig {
pub mergeable_file_extensions: Vec<String>, pub mergeable_file_extensions: Vec<String>,
} }
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
max_body_size_mb: default_max_body_size_mb(),
max_clients_per_vault: default_max_clients_per_vault(),
response_timeout: default_response_timeout(),
mergeable_file_extensions: default_mergeable_file_extensions(),
}
}
}
fn default_host() -> String { fn default_host() -> String {
debug!("Using default server host: {DEFAULT_HOST}"); debug!("Using default server host: {DEFAULT_HOST}");
DEFAULT_HOST.to_owned() DEFAULT_HOST.to_owned()
@ -60,3 +73,36 @@ fn default_mergeable_file_extensions() -> Vec<String> {
.map(|s| (*s).to_owned()) .map(|s| (*s).to_owned())
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[tokio::test]
async fn fresh_and_omitted_server_configs_use_the_documented_defaults() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.yml");
let fresh = Config::read_or_create(&path).await.unwrap();
let reread = Config::load_from_file(&path).await.unwrap();
let omitted: Config = serde_yaml::from_str("{}").unwrap();
let explicit: Config = serde_yaml::from_str("server: {}").unwrap();
for config in [fresh, reread, omitted, explicit] {
assert_eq!(config.server.host, DEFAULT_HOST);
assert_eq!(config.server.port, DEFAULT_PORT);
assert_eq!(config.server.max_body_size_mb, DEFAULT_MAX_BODY_SIZE_MB);
assert_eq!(
config.server.max_clients_per_vault,
DEFAULT_MAX_CLIENTS_PER_VAULT
);
assert_eq!(
config.server.response_timeout,
DEFAULT_RESPONSE_TIMEOUT_SECONDS
);
assert_eq!(
config.server.mergeable_file_extensions,
DEFAULT_MERGEABLE_FILE_EXTENSIONS
);
}
}
}

View file

@ -2,7 +2,10 @@ use bimap::BiHashMap;
use rand::{Rng, distr::Alphanumeric, rng}; use rand::{Rng, distr::Alphanumeric, rng};
use serde::{Deserialize, Deserializer, Serialize, de::Error}; use serde::{Deserialize, Deserializer, Serialize, de::Error};
use crate::app_state::database::models::VaultId; use crate::{
app_state::database::models::VaultId,
utils::normalize_vault_id::{normalize_string, validate_vault_id},
};
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UserConfig { pub struct UserConfig {
@ -71,9 +74,23 @@ pub enum VaultAccess {
#[derive(Debug, Deserialize, Serialize, Clone, Default)] #[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct AllowListedVaults { pub struct AllowListedVaults {
#[serde(deserialize_with = "deserialize_allowed_vaults")]
pub allowed: Vec<VaultId>, pub allowed: Vec<VaultId>,
} }
fn deserialize_allowed_vaults<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<VaultId>, D::Error> {
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|name| {
let vault = normalize_string(&name);
validate_vault_id(&vault).map_err(D::Error::custom)?;
Ok(vault)
})
.collect()
}
fn default_users() -> Vec<User> { fn default_users() -> Vec<User> {
vec![User { vec![User {
name: "admin".to_owned(), name: "admin".to_owned(),
@ -95,6 +112,24 @@ mod tests {
use super::*; use super::*;
#[test]
fn allowlists_normalize_case_and_whitespace_but_preserve_unicode_identity() {
let list: AllowListedVaults = serde_json::from_value(json!({
"allowed": [" MyVault ", "CAFÉ", "CAFE\u{301}"]
}))
.unwrap();
assert_eq!(list.allowed, ["myvault", "café", "cafe\u{301}"]);
}
#[test]
fn allowlists_reject_invalid_vault_names() {
for name in ["", " ", ".", "..", "../escaped", "a/b", "a\\b", "a\0b"] {
assert!(
serde_json::from_value::<AllowListedVaults>(json!({"allowed": [name]})).is_err()
);
}
}
#[test] #[test]
fn test_validate_users_unique_names_and_tokens() { fn test_validate_users_unique_names_and_tokens() {
let config_json = json!({ let config_json = json!({

View file

@ -4,6 +4,9 @@ mod endpoints;
pub(crate) mod requests; pub(crate) mod requests;
pub(crate) mod responses; pub(crate) mod responses;
#[cfg(test)]
mod tests;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use auth::auth_middleware; use auth::auth_middleware;
use axum::{ use axum::{

View file

@ -14,8 +14,8 @@ use log::info;
use crate::{ use crate::{
app_state::{AppState, database::models::VaultId}, app_state::{AppState, database::models::VaultId},
config::user_config::{AllowListedVaults, User, VaultAccess}, config::user_config::{AllowListedVaults, User, VaultAccess},
errors::{SyncServerError, permission_denied_error, unauthenticated_error}, errors::{SyncServerError, client_error, permission_denied_error, unauthenticated_error},
utils::normalize::normalize_string, utils::normalize_vault_id::{normalize_string, validate_vault_id},
}; };
pub async fn auth_middleware( pub async fn auth_middleware(
@ -40,6 +40,7 @@ pub async fn auth_middleware(
} }
pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> { pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> {
validate_vault_id(vault_id).map_err(client_error)?;
let user = state let user = state
.config .config
.users .users

View file

@ -11,12 +11,12 @@ use crate::{
database::models::{EventBatch, VaultId}, database::models::{EventBatch, VaultId},
}, },
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, client_error, server_error},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct EventsPath { pub struct EventsPath {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
} }

View file

@ -12,12 +12,12 @@ use crate::{
database::models::{DocumentId, VaultId, VaultUpdateId}, database::models::{DocumentId, VaultId, VaultUpdateId},
}, },
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error, server_error},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct FetchDocumentVersionContentPathParams { pub struct FetchDocumentVersionContentPathParams {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
document_id: DocumentId, document_id: DocumentId,

View file

@ -12,12 +12,12 @@ use crate::{
database::models::{DocumentId, DocumentVersion, VaultId}, database::models::{DocumentId, DocumentVersion, VaultId},
}, },
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error, server_error},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct FetchLatestDocumentVersionPathParams { pub struct FetchLatestDocumentVersionPathParams {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
document_id: DocumentId, document_id: DocumentId,

View file

@ -14,12 +14,12 @@ use crate::{
}, },
}, },
errors::{SyncServerError, server_error}, errors::{SyncServerError, server_error},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GetFileManifestPath { pub struct GetFileManifestPath {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
} }

View file

@ -14,12 +14,12 @@ use crate::{
app_state::{AppState, database::models::VaultId}, app_state::{AppState, database::models::VaultId},
consts::SUPPORTED_API_VERSION, consts::SUPPORTED_API_VERSION,
errors::SyncServerError, errors::SyncServerError,
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PingPathParams { pub struct PingPathParams {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
} }

View file

@ -14,7 +14,7 @@ use crate::{
requests::{PushContent, PutFileContent}, requests::{PushContent, PutFileContent},
responses::DocumentUpdateResponse, responses::DocumentUpdateResponse,
}, },
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
use anyhow::anyhow; use anyhow::anyhow;
use axum::{ use axum::{
@ -30,7 +30,7 @@ use sha2::{Digest, Sha256};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PutFileContentPath { pub struct PutFileContentPath {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
document_id: DocumentId, document_id: DocumentId,
} }

View file

@ -18,12 +18,12 @@ use crate::{
}, },
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, client_error, server_error},
server::{requests::PushFileManifest, responses::FileManifestUpdateResponse}, server::{requests::PushFileManifest, responses::FileManifestUpdateResponse},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PutFileManifestPath { pub struct PutFileManifestPath {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
} }

View file

@ -11,12 +11,12 @@ use crate::{
database::models::{VaultId, VaultSnapshot}, database::models::{VaultId, VaultSnapshot},
}, },
errors::{SyncServerError, server_error}, errors::{SyncServerError, server_error},
utils::normalize::normalize, utils::normalize_vault_id::normalize_vault_id,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct VaultSnapshotPath { pub struct VaultSnapshotPath {
#[serde(deserialize_with = "normalize")] #[serde(deserialize_with = "normalize_vault_id")]
vault_id: VaultId, vault_id: VaultId,
} }

Some files were not shown because too many files have changed in this diff Show more