Review pass
This commit is contained in:
parent
2a05930ae7
commit
99fa298b92
22 changed files with 621 additions and 94 deletions
|
|
@ -39,7 +39,9 @@ impl Cursors {
|
|||
) {
|
||||
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.push(ClientCursorsWithTimeToLive::new(ClientCursors {
|
||||
|
|
@ -48,8 +50,7 @@ impl Cursors {
|
|||
documents_with_cursors: document_to_cursors,
|
||||
}));
|
||||
|
||||
drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock
|
||||
self.broadcast_cursors().await;
|
||||
self.broadcast_cursors(&vault_id, all_device_cursors).await;
|
||||
}
|
||||
|
||||
pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec<ClientCursors> {
|
||||
|
|
@ -78,31 +79,42 @@ impl Cursors {
|
|||
async fn remove_expired_cursors(&self) {
|
||||
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));
|
||||
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) {
|
||||
let vault_to_cursors = self.vault_to_cursors.lock().await;
|
||||
|
||||
for (vault_id, cursors) in vault_to_cursors.iter() {
|
||||
self.broadcasts
|
||||
.send(
|
||||
vault_id.clone(),
|
||||
Notification::Cursors(CursorPositionFromServer {
|
||||
clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
async fn broadcast_cursors(&self, vault_id: &str, cursors: &[ClientCursorsWithTimeToLive]) {
|
||||
self.broadcasts
|
||||
.send(
|
||||
vault_id.to_owned(),
|
||||
Notification::Cursors(CursorPositionFromServer {
|
||||
clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
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 log::info;
|
||||
use models::VaultId;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
|
||||
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
|
||||
use tokio::sync::Mutex;
|
||||
|
|
@ -14,7 +19,15 @@ pub mod models;
|
|||
mod mutations;
|
||||
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)]
|
||||
struct PoolWithTimestamp {
|
||||
|
|
@ -41,7 +54,10 @@ pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
|
|||
|
||||
impl Database {
|
||||
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
|
||||
.with_context(|| {
|
||||
format!(
|
||||
|
|
@ -50,34 +66,25 @@ impl Database {
|
|||
)
|
||||
})?;
|
||||
|
||||
let mut connection_pools = std::collections::HashMap::new();
|
||||
|
||||
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? {
|
||||
if !entry.file_name().to_string_lossy().ends_with(".sqlite") {
|
||||
continue;
|
||||
if entry.file_type().await?.is_file()
|
||||
&& 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 {
|
||||
config: config.clone(),
|
||||
connection_pools: Arc::new(Mutex::new(connection_pools)),
|
||||
connection_pools: Arc::default(),
|
||||
};
|
||||
|
||||
// Start background task to cleanup idle connection pools
|
||||
|
|
@ -86,16 +93,21 @@ impl Database {
|
|||
Ok(database)
|
||||
}
|
||||
|
||||
async fn create_vault_database(
|
||||
config: &DatabaseConfig,
|
||||
vault: &VaultId,
|
||||
) -> Result<Pool<Sqlite>> {
|
||||
let file_name = config
|
||||
fn database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
|
||||
// Hash the normalized vault name into a fixed-length ASCII filename so
|
||||
// filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
|
||||
// macOS) cannot make separately authorized vaults share a database.
|
||||
// This is only a storage filename; the user-facing vault name is unchanged.
|
||||
let digest = Sha256::digest(vault.as_bytes());
|
||||
config
|
||||
.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()
|
||||
.filename(file_name.clone())
|
||||
.filename(file_name)
|
||||
.create_if_missing(true)
|
||||
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full)
|
||||
.busy_timeout(Duration::from_secs(3600))
|
||||
|
|
@ -126,10 +138,13 @@ impl Database {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
if !pools.contains_key(vault) {
|
||||
let pool = Self::create_vault_database(&self.config, vault).await?;
|
||||
if !pools.contains_key(&vault) {
|
||||
let file_name = Self::database_path(&self.config, &vault);
|
||||
let pool = Self::open_database(&self.config, &file_name).await?;
|
||||
pools.insert(
|
||||
vault.clone(),
|
||||
PoolWithTimestamp {
|
||||
|
|
@ -140,7 +155,7 @@ impl Database {
|
|||
}
|
||||
|
||||
let pool_with_timestamp = pools
|
||||
.get_mut(vault)
|
||||
.get_mut(&vault)
|
||||
.expect("Pool was just inserted or already exists");
|
||||
|
||||
// Update last accessed time
|
||||
|
|
|
|||
|
|
@ -22,16 +22,18 @@ pub struct StoredDocumentVersion {
|
|||
pub device_id: DeviceId,
|
||||
}
|
||||
|
||||
#[derive(TS, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(TS, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct DocumentVersionWithoutContent {
|
||||
#[ts(as = "f64")]
|
||||
pub vault_update_id: VaultUpdateId,
|
||||
#[sqlx(try_from = "uuid::fmt::Hyphenated")]
|
||||
pub document_id: DocumentId,
|
||||
pub updated_date: DateTime<Utc>,
|
||||
pub user_id: UserId,
|
||||
pub device_id: DeviceId,
|
||||
#[sqlx(try_from = "i64")]
|
||||
pub content_size: usize,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use super::{
|
||||
Database, Transaction,
|
||||
models::{
|
||||
DocumentId, EventBatch, FileManifest, StoredDocumentVersion, VaultId, VaultSnapshot,
|
||||
VaultUpdateId,
|
||||
DocumentId, DocumentVersionWithoutContent, EventBatch, FileManifest, StoredDocumentVersion,
|
||||
VaultId, VaultSnapshot, VaultUpdateId,
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, ensure};
|
||||
|
|
@ -99,8 +99,10 @@ impl Database {
|
|||
let head_event_id = Self::latest_event_id(&mut tx).await?;
|
||||
let file_manifest = Self::current_file_manifest(&mut tx).await?;
|
||||
|
||||
let versions = sqlx::query_as::<_, StoredDocumentVersion>(
|
||||
"SELECT d.* FROM latest_document_versions d
|
||||
let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
|
||||
"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
|
||||
WHERE m.file_manifest_id = ? ORDER BY d.vault_update_id",
|
||||
)
|
||||
|
|
@ -108,8 +110,6 @@ impl Database {
|
|||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let documents = versions.into_iter().map(Into::into).collect();
|
||||
|
||||
Ok(VaultSnapshot {
|
||||
head_event_id,
|
||||
file_manifest,
|
||||
|
|
|
|||
160
sync-server/src/app_state/database/tests.rs
Normal file
160
sync-server/src/app_state/database/tests.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use crate::consts::{
|
|||
DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
|
|
@ -28,6 +28,19 @@ pub struct ServerConfig {
|
|||
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 {
|
||||
debug!("Using default server host: {DEFAULT_HOST}");
|
||||
DEFAULT_HOST.to_owned()
|
||||
|
|
@ -60,3 +73,36 @@ fn default_mergeable_file_extensions() -> Vec<String> {
|
|||
.map(|s| (*s).to_owned())
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ use bimap::BiHashMap;
|
|||
use rand::{Rng, distr::Alphanumeric, rng};
|
||||
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)]
|
||||
pub struct UserConfig {
|
||||
|
|
@ -71,9 +74,23 @@ pub enum VaultAccess {
|
|||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct AllowListedVaults {
|
||||
#[serde(deserialize_with = "deserialize_allowed_vaults")]
|
||||
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> {
|
||||
vec![User {
|
||||
name: "admin".to_owned(),
|
||||
|
|
@ -95,6 +112,24 @@ mod tests {
|
|||
|
||||
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]
|
||||
fn test_validate_users_unique_names_and_tokens() {
|
||||
let config_json = json!({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ mod endpoints;
|
|||
pub(crate) mod requests;
|
||||
pub(crate) mod responses;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use auth::auth_middleware;
|
||||
use axum::{
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ use log::info;
|
|||
use crate::{
|
||||
app_state::{AppState, database::models::VaultId},
|
||||
config::user_config::{AllowListedVaults, User, VaultAccess},
|
||||
errors::{SyncServerError, permission_denied_error, unauthenticated_error},
|
||||
utils::normalize::normalize_string,
|
||||
errors::{SyncServerError, client_error, permission_denied_error, unauthenticated_error},
|
||||
utils::normalize_vault_id::{normalize_string, validate_vault_id},
|
||||
};
|
||||
|
||||
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> {
|
||||
validate_vault_id(vault_id).map_err(client_error)?;
|
||||
let user = state
|
||||
.config
|
||||
.users
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ use crate::{
|
|||
database::models::{EventBatch, VaultId},
|
||||
},
|
||||
errors::{SyncServerError, client_error, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EventsPath {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ use crate::{
|
|||
database::models::{DocumentId, VaultId, VaultUpdateId},
|
||||
},
|
||||
errors::{SyncServerError, not_found_error, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FetchDocumentVersionContentPathParams {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
|
||||
document_id: DocumentId,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ use crate::{
|
|||
database::models::{DocumentId, DocumentVersion, VaultId},
|
||||
},
|
||||
errors::{SyncServerError, not_found_error, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FetchLatestDocumentVersionPathParams {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
|
||||
document_id: DocumentId,
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ use crate::{
|
|||
},
|
||||
},
|
||||
errors::{SyncServerError, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetFileManifestPath {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ use crate::{
|
|||
app_state::{AppState, database::models::VaultId},
|
||||
consts::SUPPORTED_API_VERSION,
|
||||
errors::SyncServerError,
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PingPathParams {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::{
|
|||
requests::{PushContent, PutFileContent},
|
||||
responses::DocumentUpdateResponse,
|
||||
},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
|
|
@ -30,7 +30,7 @@ use sha2::{Digest, Sha256};
|
|||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutFileContentPath {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
document_id: DocumentId,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ use crate::{
|
|||
},
|
||||
errors::{SyncServerError, client_error, server_error},
|
||||
server::{requests::PushFileManifest, responses::FileManifestUpdateResponse},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutFileManifestPath {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ use crate::{
|
|||
database::models::{VaultId, VaultSnapshot},
|
||||
},
|
||||
errors::{SyncServerError, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VaultSnapshotPath {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
},
|
||||
},
|
||||
errors::{SyncServerError, client_error, server_error},
|
||||
utils::normalize::normalize,
|
||||
utils::normalize_vault_id::normalize_vault_id,
|
||||
};
|
||||
use axum::{
|
||||
extract::{
|
||||
|
|
@ -26,7 +26,7 @@ use tokio::sync::broadcast::error::RecvError;
|
|||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WebSocketPathParams {
|
||||
#[serde(deserialize_with = "normalize")]
|
||||
#[serde(deserialize_with = "normalize_vault_id")]
|
||||
vault_id: VaultId,
|
||||
}
|
||||
|
||||
|
|
|
|||
121
sync-server/src/server/tests.rs
Normal file
121
sync-server/src/server/tests.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use super::*;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn app(directory: &tempfile::TempDir) -> Router {
|
||||
let mut config: Config = serde_json::from_value(json!({
|
||||
"users": { "user_configs": [
|
||||
{ "name": "admin", "token": "admin", "vault_access": { "type": "allow_access_to_all" } },
|
||||
{ "name": "mixed", "token": "mixed", "vault_access": { "type": "allow_list", "allowed": [" MyVault "] } },
|
||||
{ "name": "composed", "token": "composed", "vault_access": { "type": "allow_list", "allowed": ["café"] } },
|
||||
{ "name": "decomposed", "token": "decomposed", "vault_access": { "type": "allow_list", "allowed": ["cafe\u{301}"] } }
|
||||
] }
|
||||
})).unwrap();
|
||||
config.database.databases_directory_path = directory.path().join("databases");
|
||||
let state = AppState::try_new(config).await.unwrap();
|
||||
get_authed_routes(state.clone()).with_state(state)
|
||||
}
|
||||
|
||||
async fn request(app: Router, path: &str, token: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.method(if body.is_some() {
|
||||
Method::PUT
|
||||
} else {
|
||||
Method::GET
|
||||
})
|
||||
.header(http::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header("Device-Id", "test")
|
||||
.body(body.map_or_else(Body::empty, |value| Body::from(value.to_string())))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_rejects_encoded_traversal_and_accepts_normalized_allowlists() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let app = app(&directory).await;
|
||||
for vault in [
|
||||
"..%2Fescaped",
|
||||
"%2Fabsolute",
|
||||
"a%5Cb",
|
||||
"%2E%2E",
|
||||
"%20",
|
||||
"a%00b",
|
||||
] {
|
||||
let (status, _) = request(
|
||||
app.clone(),
|
||||
&format!("/vaults/{vault}/vault_snapshot"),
|
||||
"admin",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "vault: {vault}");
|
||||
}
|
||||
assert!(!directory.path().join("escaped.sqlite").exists());
|
||||
for vault in ["MyVault", "myvault", "%20MYVAULT%20"] {
|
||||
let (status, _) = request(
|
||||
app.clone(),
|
||||
&format!("/vaults/{vault}/file_manifest"),
|
||||
"mixed",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "vault: {vault}");
|
||||
}
|
||||
assert_eq!(
|
||||
request(app, "/vaults/other/file_manifest", "mixed", None)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_keeps_unicode_vaults_separate_across_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let router = app(&directory).await;
|
||||
let document = uuid::Uuid::new_v4();
|
||||
let composed = format!("/vaults/caf%C3%A9/documents/{document}");
|
||||
let decomposed = format!("/vaults/cafe%CC%81/documents/{document}");
|
||||
let push = json!({
|
||||
"requestId": uuid::Uuid::new_v4(), "parentVersionId": null,
|
||||
"content": { "type": "Snapshot", "value": "aGVsbG8=" }
|
||||
});
|
||||
assert_eq!(
|
||||
request(router, &composed, "composed", Some(push)).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let reopened = app(&directory).await;
|
||||
assert_eq!(
|
||||
request(reopened.clone(), &composed, "decomposed", None)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
request(reopened.clone(), &decomposed, "decomposed", None)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
let (status, version) = request(reopened, &composed, "composed", None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(version["contentBase64"], "aGVsbG8=");
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
pub mod log_level;
|
||||
pub mod normalize;
|
||||
pub mod normalize_vault_id;
|
||||
pub mod portable_path;
|
||||
pub mod rotating_file_writer;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue