diff --git a/sync-server/src/app_state/database/get_document_version.rs b/sync-server/src/app_state/database/get_document_version.rs deleted file mode 100644 index c4113d8b..00000000 --- a/sync-server/src/app_state/database/get_document_version.rs +++ /dev/null @@ -1,41 +0,0 @@ -use anyhow::{Context as _, Result}; -use sqlx::types::chrono::Utc; -use uuid::fmt::Hyphenated; - -use super::models::{StoredDocumentVersion, VaultId, VaultUpdateId}; -use super::{Database, Transaction}; - -impl Database { - pub async fn get_document_version( - &self, - vault: &VaultId, - vault_update_id: VaultUpdateId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let query = sqlx::query_as!( - StoredDocumentVersion, - r#" - select - vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - content, - is_deleted, - user_id, - device_id - from documents - where vault_update_id = ?"#, - vault_update_id - ); - - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await - } else { - query - .fetch_optional(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch document version") - } -} diff --git a/sync-server/src/app_state/database/get_latest_document.rs b/sync-server/src/app_state/database/get_latest_document.rs deleted file mode 100644 index 0bb6ecb6..00000000 --- a/sync-server/src/app_state/database/get_latest_document.rs +++ /dev/null @@ -1,43 +0,0 @@ -use anyhow::{Context as _, Result}; -use sqlx::types::chrono::Utc; -use uuid::fmt::Hyphenated; - -use super::models::{DocumentId, StoredDocumentVersion, VaultId}; -use super::{Database, Transaction}; - -impl Database { - pub async fn get_latest_document( - &self, - vault: &VaultId, - document_id: &DocumentId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let document_id = document_id.as_hyphenated(); - let query = sqlx::query_as!( - StoredDocumentVersion, - r#" - select - vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - content, - is_deleted, - user_id, - device_id - from latest_document_versions - where document_id = ? - "#, - document_id - ); - - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await - } else { - query - .fetch_optional(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch latest document version") - } -} diff --git a/sync-server/src/app_state/database/get_latest_document_by_path.rs b/sync-server/src/app_state/database/get_latest_document_by_path.rs deleted file mode 100644 index 95e49875..00000000 --- a/sync-server/src/app_state/database/get_latest_document_by_path.rs +++ /dev/null @@ -1,46 +0,0 @@ -use anyhow::{Context as _, Result}; -use sqlx::types::chrono::Utc; -use uuid::fmt::Hyphenated; - -use super::models::{StoredDocumentVersion, VaultId}; -use super::{Database, Transaction}; - -impl Database { - pub async fn get_latest_document_by_path( - &self, - vault: &VaultId, - relative_path: &str, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let query = sqlx::query_as!( - StoredDocumentVersion, - r#" - select - vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - content, - is_deleted, - user_id, - device_id - from latest_document_versions - where relative_path = ? and is_deleted = false - order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however, - -- multiple documents can have the same `relative_path`, if they have been deleted. That's - -- why we only care about the latest version of the document with the given relative path. - limit 1 - "#, - relative_path - ); - - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await - } else { - query - .fetch_optional(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch latest document version") - } -} diff --git a/sync-server/src/app_state/database/get_latest_documents.rs b/sync-server/src/app_state/database/get_latest_documents.rs deleted file mode 100644 index 700a505c..00000000 --- a/sync-server/src/app_state/database/get_latest_documents.rs +++ /dev/null @@ -1,56 +0,0 @@ -use anyhow::{Context as _, Result}; -use sqlx::types::chrono::Utc; -use uuid::fmt::Hyphenated; - -use super::models::{DocumentVersionWithoutContent, VaultId}; -use super::{Database, Transaction}; - -impl Database { - /// Return the latest state of all documents in the vault - pub async fn get_latest_documents( - &self, - vault: &VaultId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let query = sqlx::query!( - r#" - select - vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - is_deleted, - user_id, - device_id, - length(content) as "content_size: u64" - from latest_document_versions - order by vault_update_id - "#, - ); - - if let Some(transaction) = transaction { - query.fetch_all(&mut **transaction).await - } else { - query - .fetch_all(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch latest documents") - .map(|rows| { - rows.into_iter() - .map(|row| DocumentVersionWithoutContent { - vault_update_id: row.vault_update_id, - document_id: row.document_id.into(), - relative_path: row.relative_path, - updated_date: row.updated_date, - is_deleted: row.is_deleted, - user_id: row.user_id, - device_id: row.device_id, - content_size: row - .content_size - .expect("Content size can't be null but sqlx can't infer it"), - }) - .collect() - }) - } -} diff --git a/sync-server/src/app_state/database/get_latest_documents_since.rs b/sync-server/src/app_state/database/get_latest_documents_since.rs deleted file mode 100644 index 48c2f0e9..00000000 --- a/sync-server/src/app_state/database/get_latest_documents_since.rs +++ /dev/null @@ -1,62 +0,0 @@ -use anyhow::{Context as _, Result}; -use sqlx::types::chrono::Utc; -use uuid::fmt::Hyphenated; - -use super::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}; -use super::{Database, Transaction}; - -impl Database { - /// Return the latest state of all documents (including deleted) in the - /// vault which have changed since the given update id - pub async fn get_latest_documents_since( - &self, - vault: &VaultId, - vault_update_id: VaultUpdateId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let query = sqlx::query!( - r#" - select - vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - is_deleted, - user_id, - device_id, - length(content) as "content_size: u64" - from latest_document_versions - where vault_update_id > ? - order by vault_update_id - "#, - vault_update_id - ); - - if let Some(transaction) = transaction { - query.fetch_all(&mut **transaction).await - } else { - query - .fetch_all(&self.get_connection_pool(vault).await?) - .await - } - .with_context(|| { - format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`") - }) - .map(|rows| { - rows.into_iter() - .map(|row| DocumentVersionWithoutContent { - vault_update_id: row.vault_update_id, - document_id: row.document_id.into(), - relative_path: row.relative_path, - updated_date: row.updated_date, - is_deleted: row.is_deleted, - user_id: row.user_id, - device_id: row.device_id, - content_size: row - .content_size - .expect("Content size can't be null but sqlx can't infer it"), - }) - .collect() - }) - } -} diff --git a/sync-server/src/app_state/database/get_max_update_id_in_vault.rs b/sync-server/src/app_state/database/get_max_update_id_in_vault.rs deleted file mode 100644 index cc4a43c7..00000000 --- a/sync-server/src/app_state/database/get_max_update_id_in_vault.rs +++ /dev/null @@ -1,29 +0,0 @@ -use anyhow::{Context as _, Result}; - -use super::models::VaultId; -use super::{Database, Transaction}; - -impl Database { - pub async fn get_max_update_id_in_vault( - &self, - vault: &VaultId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result { - let query = sqlx::query!( - r#" - select coalesce(max(vault_update_id), 0) as max_vault_update_id - from documents - "#, - ); - - if let Some(transaction) = transaction { - query.fetch_one(&mut **transaction).await - } else { - query - .fetch_one(&self.get_connection_pool(vault).await?) - .await - } - .map(|row| row.max_vault_update_id) - .context("Cannot fetch max update id in vault") - } -} diff --git a/sync-server/src/app_state/database/get_push_acknowledgement.rs b/sync-server/src/app_state/database/get_push_acknowledgement.rs deleted file mode 100644 index cd8cd593..00000000 --- a/sync-server/src/app_state/database/get_push_acknowledgement.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::{Context as _, Result}; - -use super::models::{StoredDocumentVersion, VaultId}; -use super::{Database, Transaction}; - -impl Database { - /// Look up the immutable version acknowledged by a previous push. - pub async fn get_push_acknowledgement( - &self, - vault_id: &VaultId, - request_id: uuid::Uuid, - transaction: &mut Transaction<'_>, - ) -> Result, StoredDocumentVersion)>> { - let request_id = request_id.hyphenated().to_string(); - let acknowledgement = sqlx::query!( - "select vault_update_id, request_fingerprint from push_acknowledgements where request_id = ?", - request_id - ) - .fetch_optional(&mut **transaction) - .await - .context("Cannot fetch push acknowledgement")?; - - match acknowledgement { - Some(acknowledgement) => { - let version = self - .get_document_version( - vault_id, - acknowledgement.vault_update_id, - Some(transaction), - ) - .await? - .context("Acknowledged document version is missing")?; - Ok(Some((acknowledgement.request_fingerprint, version))) - } - None => Ok(None), - } - } -} diff --git a/sync-server/src/app_state/database/insert_document_version.rs b/sync-server/src/app_state/database/insert_document_version.rs deleted file mode 100644 index 16752471..00000000 --- a/sync-server/src/app_state/database/insert_document_version.rs +++ /dev/null @@ -1,79 +0,0 @@ -use anyhow::{Context as _, Result}; - -use super::models::{StoredDocumentVersion, VaultId}; -use super::{Database, Transaction}; -use crate::app_state::websocket::models::{ - WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate, -}; - -impl Database { - /// Commit the snapshot and acknowledgement atomically, then notify subscribers. - pub async fn insert_document_version( - &self, - vault_id: &VaultId, - version: &StoredDocumentVersion, - request_id: uuid::Uuid, - request_fingerprint: &[u8], - mut transaction: Transaction<'_>, - ) -> Result<()> { - let document_id = version.document_id.as_hyphenated(); - sqlx::query!( - r#" - insert into documents ( - vault_update_id, - document_id, - relative_path, - updated_date, - content, - is_deleted, - user_id, - device_id - ) - values (?, ?, ?, ?, ?, ?, ?, ?) - "#, - version.vault_update_id, - document_id, - version.relative_path, - version.updated_date, - version.content, - version.is_deleted, - version.user_id, - version.device_id - ) - .execute(&mut *transaction) - .await - .context("Cannot insert document version")?; - - let request_id = request_id.hyphenated().to_string(); - sqlx::query!( - "insert into push_acknowledgements (request_id, request_fingerprint, vault_update_id) values (?, ?, ?)", - request_id, - request_fingerprint, - version.vault_update_id - ) - .execute(&mut *transaction) - .await - .context("Cannot save push acknowledgement")?; - - transaction - .commit() - .await - .context("Failed to commit push")?; - - // notifying the listeners here means that no update can circumvent this step - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::with_origin( - version.device_id.clone(), - WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: vec![version.clone().into()], - is_initial_sync: false, - }), - ), - ) - .await; - - Ok(()) - } -} diff --git a/sync-server/src/server/endpoints/fetch_latest_documents.rs b/sync-server/src/server/endpoints/fetch_latest_documents.rs deleted file mode 100644 index eee16441..00000000 --- a/sync-server/src/server/endpoints/fetch_latest_documents.rs +++ /dev/null @@ -1,36 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use log::debug; -use serde::Deserialize; - -use crate::{ - app_state::{ - AppState, - database::models::{DocumentVersionWithoutContent, VaultId}, - }, - errors::{SyncServerError, server_error}, - utils::normalize::normalize, -}; - -#[derive(Deserialize)] -pub struct FetchLatestDocumentsPathParams { - #[serde(deserialize_with = "normalize")] - vault_id: VaultId, -} - -#[axum::debug_handler] -pub async fn fetch_latest_documents( - Path(FetchLatestDocumentsPathParams { vault_id }): Path, - State(state): State, -) -> Result>, SyncServerError> { - debug!("Fetching latest documents in {vault_id}"); - - state - .database - .get_latest_documents(&vault_id, None) - .await - .map(Json) - .map_err(server_error) -} diff --git a/sync-server/src/server/endpoints/push_document/tests.rs b/sync-server/src/server/endpoints/push_document/tests.rs deleted file mode 100644 index 291c2def..00000000 --- a/sync-server/src/server/endpoints/push_document/tests.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::path::PathBuf; - -use super::*; -use crate::{ - app_state::{ - database::models::{DocumentVersionWithoutContent, VaultUpdateId}, - websocket::broadcasts::Broadcasts, - }, - config::{database_config::DatabaseConfig, server_config::ServerConfig}, -}; - -struct Push { - document_id: DocumentId, - request_id: uuid::Uuid, - parent_version_id: Option, - relative_path: String, - content: PushContent, - user_id: String, - device_id: String, -} - -impl Push { - async fn send( - self, - database: &Database, - vault: &VaultId, - ) -> Result { - accept_push( - database, - vault, - self.document_id, - self.user_id, - self.device_id, - PushDocument { - request_id: self.request_id, - parent_version_id: self.parent_version_id, - relative_path: self.relative_path, - content: self.content, - }, - ) - .await - } -} - -struct TestVault { - directory: PathBuf, - database: Database, - vault: VaultId, -} - -impl TestVault { - async fn new() -> Self { - let directory = - std::env::temp_dir().join(format!("vaultlink-cas-{}", uuid::Uuid::new_v4())); - let database = Self::open(&directory).await; - Self { - directory, - database, - vault: "test".to_owned(), - } - } - - async fn open(directory: &std::path::Path) -> Database { - Database::try_new( - &DatabaseConfig { - databases_directory_path: directory.to_owned(), - ..Default::default() - }, - &Broadcasts::new(&ServerConfig { - max_clients_per_vault: 16, - ..Default::default() - }), - ) - .await - .unwrap() - } - - async fn send(&self, push: Push) -> DocumentUpdateResponse { - push.send(&self.database, &self.vault).await.unwrap() - } - - async fn latest(&self, document: DocumentId) -> StoredDocumentVersion { - self.database - .get_latest_document(&self.vault, &document, None) - .await - .unwrap() - .unwrap() - } -} - -impl Drop for TestVault { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.directory); - } -} - -fn snapshot(document_id: DocumentId, parent: Option, text: &str) -> Push { - Push { - document_id, - request_id: uuid::Uuid::new_v4(), - parent_version_id: parent, - relative_path: "note.md".to_owned(), - content: PushContent::Snapshot(STANDARD.encode(text)), - user_id: "user".to_owned(), - device_id: "device".to_owned(), - } -} - -fn accepted(response: DocumentUpdateResponse) -> DocumentVersionWithoutContent { - match response { - DocumentUpdateResponse::Accepted(version) => version, - response @ DocumentUpdateResponse::StaleBase(_) => { - panic!("Expected Accepted, got {response:?}") - } - } -} - -#[tokio::test] -async fn json_snapshot_preserves_bytes_and_rejects_invalid_base64_without_a_version() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let content = [0, 255, 13, 10, 42]; - let request: PushDocument = serde_json::from_value(serde_json::json!({ - "requestId": uuid::Uuid::new_v4(), - "parentVersionId": null, - "relativePath": "snapshot.bin", - "content": { "type": "Snapshot", "value": STANDARD.encode(content) }, - })) - .unwrap(); - let base = accepted( - accept_push( - &vault.database, - &vault.vault, - document, - "user".to_owned(), - "device".to_owned(), - request, - ) - .await - .unwrap(), - ); - assert_eq!(vault.latest(document).await.content, content); - - let invalid: PushDocument = serde_json::from_value(serde_json::json!({ - "requestId": uuid::Uuid::new_v4(), - "parentVersionId": base.vault_update_id, - "relativePath": "snapshot.bin", - "content": { "type": "Snapshot", "value": "not base64!" }, - })) - .unwrap(); - assert!(matches!( - accept_push( - &vault.database, - &vault.vault, - document, - "user".to_owned(), - "device".to_owned(), - invalid - ) - .await, - Err(SyncServerError::ClientError(_)) - )); - assert_eq!( - vault.latest(document).await.vault_update_id, - base.vault_update_id - ); -} - -#[tokio::test] -async fn concurrent_writers_only_accept_one_snapshot_and_preserve_the_base() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let base = accepted(vault.send(snapshot(document, None, "base")).await); - let (left, right) = tokio::join!( - vault.send(snapshot(document, Some(base.vault_update_id), "left")), - vault.send(snapshot(document, Some(base.vault_update_id), "right")), - ); - let (winner, rejected) = match (left, right) { - (DocumentUpdateResponse::Accepted(winner), DocumentUpdateResponse::StaleBase(rejected)) - | (DocumentUpdateResponse::StaleBase(rejected), DocumentUpdateResponse::Accepted(winner)) => { - (winner, rejected) - } - outcomes => panic!("Expected one accepted push and one stale base: {outcomes:?}"), - }; - let latest = vault.latest(document).await; - assert_eq!(winner.vault_update_id, rejected.vault_update_id); - assert_eq!( - STANDARD.decode(rejected.content_base64).unwrap(), - latest.content - ); - assert!(latest.content == b"left" || latest.content == b"right"); - let original = vault - .database - .get_document_version(&vault.vault, base.vault_update_id, None) - .await - .unwrap() - .unwrap(); - assert_eq!(original.content, b"base"); - assert_eq!( - vault - .database - .get_max_update_id_in_vault(&vault.vault, None) - .await - .unwrap(), - 2 - ); -} - -#[tokio::test] -async fn acknowledgements_survive_reopening_and_return_the_original_version() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let create = snapshot(document, None, "created"); - let create_id = create.request_id; - let base = accepted(vault.send(create).await); - let update = snapshot(document, Some(base.vault_update_id), "updated"); - let update_id = update.request_id; - let updated = accepted(vault.send(update).await); - let latest = accepted( - vault - .send(snapshot(document, Some(updated.vault_update_id), "newest")) - .await, - ); - - let reopened = TestVault::open(&vault.directory).await; - for (request_id, parent, text, original) in [ - (create_id, None, "created", base), - (update_id, Some(1), "updated", updated), - ] { - let mut retry = snapshot(document, parent, text); - retry.request_id = request_id; - retry.device_id = "new-session-after-restart".to_owned(); - let acknowledged = accepted(retry.send(&reopened, &vault.vault).await.unwrap()); - assert_eq!( - serde_json::to_value(acknowledged).unwrap(), - serde_json::to_value(original).unwrap() - ); - } - assert_eq!( - vault.latest(document).await.vault_update_id, - latest.vault_update_id - ); - - let mut reused = snapshot(uuid::Uuid::new_v4(), None, "wrong document"); - reused.request_id = create_id; - assert!(matches!( - reused.send(&reopened, &vault.vault).await, - Err(SyncServerError::ClientError(_)) - )); - - let mut changed_path = snapshot(document, None, "created"); - changed_path.relative_path = "other.md".to_owned(); - let mut changed_operation = snapshot(document, None, "created"); - changed_operation.content = PushContent::Delete; - for mut changed_payload in [ - snapshot(document, None, "different content"), - snapshot(document, Some(1), "created"), - changed_path, - changed_operation, - ] { - changed_payload.request_id = create_id; - assert!(matches!( - changed_payload.send(&reopened, &vault.vault).await, - Err(SyncServerError::ClientError(_)) - )); - } -} - -#[tokio::test] -async fn deletes_compare_the_base_and_replay_without_resurrecting_documents() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let base = accepted(vault.send(snapshot(document, None, "base")).await); - let updated = accepted( - vault - .send(snapshot(document, Some(base.vault_update_id), "edited")) - .await, - ); - let mut delete = snapshot(document, Some(base.vault_update_id), ""); - delete.content = PushContent::Delete; - assert!(matches!( - vault.send(delete).await, - DocumentUpdateResponse::StaleBase(_) - )); - assert!(!vault.latest(document).await.is_deleted); - - let mut delete = snapshot(document, Some(updated.vault_update_id), ""); - delete.content = PushContent::Delete; - let request_id = delete.request_id; - let deleted = accepted(vault.send(delete).await); - assert!(deleted.is_deleted); - assert_eq!(vault.latest(document).await.content, b"edited"); - let mut retry = snapshot(document, Some(updated.vault_update_id), ""); - retry.content = PushContent::Delete; - retry.request_id = request_id; - assert_eq!( - accepted(vault.send(retry).await).vault_update_id, - deleted.vault_update_id - ); - - let terminal = vault - .send(snapshot( - document, - Some(deleted.vault_update_id), - "resurrect", - )) - .await; - assert!(matches!(terminal, DocumentUpdateResponse::StaleBase(version) if version.is_deleted)); - let recreated = accepted( - vault - .send(snapshot(uuid::Uuid::new_v4(), None, "new identity")) - .await, - ); - assert_eq!(recreated.relative_path, "note.md"); -} - -#[tokio::test] -async fn deleting_a_missing_document_without_a_parent_is_a_client_error() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let mut delete = snapshot(document, None, ""); - delete.content = PushContent::Delete; - assert!(matches!( - delete.send(&vault.database, &vault.vault).await, - Err(SyncServerError::ClientError(_)) - )); - assert_eq!( - vault - .database - .get_latest_document(&vault.vault, &document, None) - .await - .unwrap(), - None - ); -} - -#[tokio::test] -async fn transport_diffs_reconstruct_exact_snapshots_and_reject_invalid_bases() { - let vault = TestVault::new().await; - let document = uuid::Uuid::new_v4(); - let original = "Hello world 🌍"; - let desired = "Hi beautiful world 🌍"; - let base = accepted(vault.send(snapshot(document, None, original)).await); - let mut update = snapshot(document, Some(base.vault_update_id), ""); - update.content = - PushContent::Diff(EditedText::from_strings(original, &desired.into()).to_diff()); - let updated = accepted(vault.send(update).await); - assert_eq!(vault.latest(document).await.content, desired.as_bytes()); - - for invalid_length in [i64::MIN, i64::MAX] { - let mut malformed = snapshot(document, Some(updated.vault_update_id), ""); - malformed.content = PushContent::Diff(vec![NumberOrText::Number(invalid_length)]); - assert!(matches!( - malformed.send(&vault.database, &vault.vault).await, - Err(SyncServerError::ClientError(_)) - )); - } - assert_eq!( - vault.latest(document).await.vault_update_id, - updated.vault_update_id - ); - - let another = accepted( - vault - .send(snapshot(uuid::Uuid::new_v4(), None, "another document")) - .await, - ); - let mut foreign_base = snapshot(document, Some(another.vault_update_id), ""); - foreign_base.content = PushContent::Diff(vec![]); - assert!( - matches!(vault.send(foreign_base).await, DocumentUpdateResponse::StaleBase(version) if version.vault_update_id == updated.vault_update_id) - ); - - let binary_document = uuid::Uuid::new_v4(); - let mut binary = snapshot(binary_document, None, ""); - binary.content = PushContent::Snapshot(STANDARD.encode([0xff])); - let binary_base = accepted(vault.send(binary).await); - let mut invalid_text = snapshot(binary_document, Some(binary_base.vault_update_id), ""); - invalid_text.content = PushContent::Diff(vec![]); - assert!(matches!( - invalid_text.send(&vault.database, &vault.vault).await, - Err(SyncServerError::ClientError(_)) - )); - assert_eq!( - vault.latest(binary_document).await.vault_update_id, - binary_base.vault_update_id - ); -} - -#[tokio::test] -async fn renames_allocate_paths_atomically_without_rewriting_history() { - let vault = TestVault::new().await; - let first = uuid::Uuid::new_v4(); - let second = uuid::Uuid::new_v4(); - let base = accepted(vault.send(snapshot(first, None, "first")).await); - let collision = accepted(vault.send(snapshot(second, None, "second")).await); - assert_ne!(base.relative_path, collision.relative_path); - - let mut rename = snapshot(first, Some(base.vault_update_id), "first"); - rename.relative_path = "renamed.md".to_owned(); - let renamed = accepted(vault.send(rename).await); - let mut stale_rename = snapshot(first, Some(base.vault_update_id), "first"); - stale_rename.relative_path = "stale.md".to_owned(); - assert!( - matches!(vault.send(stale_rename).await, DocumentUpdateResponse::StaleBase(version) if version.relative_path == renamed.relative_path) - ); - - let mut move_into_occupied = snapshot(second, Some(collision.vault_update_id), "second"); - move_into_occupied.relative_path = renamed.relative_path.clone(); - let allocated = accepted(vault.send(move_into_occupied).await); - assert_ne!(allocated.relative_path, renamed.relative_path); - assert_eq!(vault.latest(first).await.relative_path, "renamed.md"); - assert_eq!( - vault - .database - .get_document_version(&vault.vault, base.vault_update_id, None) - .await - .unwrap() - .unwrap() - .relative_path, - "note.md" - ); -} diff --git a/sync-server/src/utils/dedup_paths.rs b/sync-server/src/utils/dedup_paths.rs deleted file mode 100644 index bc687f6a..00000000 --- a/sync-server/src/utils/dedup_paths.rs +++ /dev/null @@ -1,150 +0,0 @@ -use regex::Regex; - -pub fn dedup_paths(path: &str) -> impl Iterator { - let mut path_parts = path.split('/').collect::>(); - let file_name = path_parts.pop().unwrap().to_owned(); - - let mut directory = path_parts.join("/"); - if !directory.is_empty() { - directory.push('/'); - } - - // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should split as ".config" + ".json" - let is_simple_dotfile = file_name.starts_with('.') && file_name.matches('.').count() == 1; - - let (stem, extension) = if is_simple_dotfile { - (file_name.clone(), String::new()) - } else { - // Regular file or dotfile with extension - let name_parts = file_name.rsplitn(2, '.').collect::>(); - let mut reverse_parts = name_parts.into_iter().rev(); - match (reverse_parts.next(), reverse_parts.next()) { - (Some(stem), maybe_extension) => ( - stem.to_owned(), - maybe_extension - .map(|ext| format!(".{ext}")) - .unwrap_or_default(), - ), - _ => unreachable!("Path must have at least one part"), - } - }; - - let regex = Regex::new(r" \((\d+)\)$").unwrap(); - let start_number = regex - .captures(&stem) - .and_then(|caps| caps.get(1)) - .and_then(|m| m.as_str().parse::().ok()) - .unwrap_or(0); - - let clean_stem = regex.replace(&stem, "").to_string(); - - (start_number..).map(move |dedup_number| { - if dedup_number == 0 { - format!("{directory}{clean_stem}{extension}") - } else { - format!("{directory}{clean_stem} ({dedup_number}){extension}") - } - }) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_dedup_paths() { - let mut deduped = dedup_paths("file.txt"); - assert_eq!(deduped.next(), Some("file.txt".to_owned())); - assert_eq!(deduped.next(), Some("file (1).txt".to_owned())); - assert_eq!(deduped.next(), Some("file (2).txt".to_owned())); - - let mut deduped = dedup_paths("file"); - assert_eq!(deduped.next(), Some("file".to_owned())); - assert_eq!(deduped.next(), Some("file (1)".to_owned())); - assert_eq!(deduped.next(), Some("file (2)".to_owned())); - - let mut deduped = dedup_paths("file (51).md"); - assert_eq!(deduped.next(), Some("file (51).md".to_owned())); - assert_eq!(deduped.next(), Some("file (52).md".to_owned())); - assert_eq!(deduped.next(), Some("file (53).md".to_owned())); - - let mut deduped = dedup_paths("file (5)"); - assert_eq!(deduped.next(), Some("file (5)".to_owned())); - assert_eq!(deduped.next(), Some("file (6)".to_owned())); - assert_eq!(deduped.next(), Some("file (7)".to_owned())); - - let mut deduped = dedup_paths("my/path.with.dots/file (5).md"); - assert_eq!( - deduped.next(), - Some("my/path.with.dots/file (5).md".to_owned()) - ); - assert_eq!( - deduped.next(), - Some("my/path.with.dots/file (6).md".to_owned()) - ); - - let mut deduped = dedup_paths("my/path.with.dots/file (5)"); - assert_eq!( - deduped.next(), - Some("my/path.with.dots/file (5)".to_owned()) - ); - assert_eq!( - deduped.next(), - Some("my/path.with.dots/file (6)".to_owned()) - ); - } - - #[test] - fn test_regex_capturing_group() { - // Single digit in parentheses - let mut deduped = dedup_paths("document (5).md"); - assert_eq!(deduped.next(), Some("document (5).md".to_owned())); - assert_eq!(deduped.next(), Some("document (6).md".to_owned())); - assert_eq!(deduped.next(), Some("document (7).md".to_owned())); - - // Multi-digit number - let mut deduped = dedup_paths("report (123).pdf"); - assert_eq!(deduped.next(), Some("report (123).pdf".to_owned())); - assert_eq!(deduped.next(), Some("report (124).pdf".to_owned())); - assert_eq!(deduped.next(), Some("report (125).pdf".to_owned())); - - // Number without extension - let mut deduped = dedup_paths("folder (99)"); - assert_eq!(deduped.next(), Some("folder (99)".to_owned())); - assert_eq!(deduped.next(), Some("folder (100)".to_owned())); - assert_eq!(deduped.next(), Some("folder (101)".to_owned())); - } - - #[test] - fn test_dedup_dotfiles() { - // Simple dotfile (no extension) - let mut deduped = dedup_paths(".gitignore"); - assert_eq!(deduped.next(), Some(".gitignore".to_owned())); - assert_eq!(deduped.next(), Some(".gitignore (1)".to_owned())); - assert_eq!(deduped.next(), Some(".gitignore (2)".to_owned())); - - // Dotfile with extension - let mut deduped = dedup_paths(".config.json"); - assert_eq!(deduped.next(), Some(".config.json".to_owned())); - assert_eq!(deduped.next(), Some(".config (1).json".to_owned())); - assert_eq!(deduped.next(), Some(".config (2).json".to_owned())); - - // Dotfile with number - let mut deduped = dedup_paths(".gitignore (5)"); - assert_eq!(deduped.next(), Some(".gitignore (5)".to_owned())); - assert_eq!(deduped.next(), Some(".gitignore (6)".to_owned())); - assert_eq!(deduped.next(), Some(".gitignore (7)".to_owned())); - - // Dotfile with extension and number - let mut deduped = dedup_paths(".config (3).json"); - assert_eq!(deduped.next(), Some(".config (3).json".to_owned())); - assert_eq!(deduped.next(), Some(".config (4).json".to_owned())); - assert_eq!(deduped.next(), Some(".config (5).json".to_owned())); - - // Dotfile in subdirectory - let mut deduped = dedup_paths("my/path/.gitignore"); - assert_eq!(deduped.next(), Some("my/path/.gitignore".to_owned())); - assert_eq!(deduped.next(), Some("my/path/.gitignore (1)".to_owned())); - assert_eq!(deduped.next(), Some("my/path/.gitignore (2)".to_owned())); - } -} diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs deleted file mode 100644 index 7629d8f1..00000000 --- a/sync-server/src/utils/find_first_available_path.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::app_state::database::models::VaultId; -use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths}; -use anyhow::Result; -use log::{debug, info}; - -pub async fn find_first_available_path( - vault_id: &VaultId, - sanitized_relative_path: &str, - database: &crate::app_state::database::Database, - transaction: &mut Transaction<'_>, -) -> Result { - 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)) - .await? - .is_none() - { - info!("Selected available path: `{candidate}`"); - return Ok(candidate); - } - } - - unreachable!("dedup_paths produces infinite paths"); -} diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs deleted file mode 100644 index 9703225c..00000000 --- a/sync-server/src/utils/sanitize_path.rs +++ /dev/null @@ -1,34 +0,0 @@ -/// 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 { - let options = sanitize_filename::Options { - truncate: true, - windows: true, // Windows is the lowest common denominator - replacement: "", - }; - - path.split('/') - .map(|part| { - let proposal = sanitize_filename::sanitize_with_options(part, options.clone()); - if !part.is_empty() && proposal.is_empty() { - "_".to_owned() - } else { - proposal - } - }) - .collect::>() - .join("/") -} - -#[cfg(test)] -mod test { - use super::*; - - #[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/_"); - } -}